Compare commits

...
Author SHA1 Message Date
Shivam MishraandGitHub b603832b2e Merge branch 'develop' into chore/notification-logging 2024-08-22 18:24:49 +05:30
7c2353c7d9 chore: Repalce Hook Mixin with useHook composable [CW-3454] (#9994)
# Pull Request Template

## Description

Replace Hook mixin with useHook composable

Fixes # (issue)

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-08-22 16:48:02 +05:30
Shivam Mishra 09a610a442 refactor: capture_exception_with_sentry 2024-08-22 16:47:32 +05:30
Sivin VargheseandGitHub c80de24ac1 fix: TypeError Unhandled Promise Rejection: null is not an object (evaluating 'e[Symbol.iterator]') (#10007)
# Pull Request Template

## Description

**Cause of Issue**

The problem was that the `clearSelectedState` action was being
dispatched late in the component lifecycle. By the time it was called,
child components like `ConversationBox` and `MessagesView` had already
been mounted and were trying to access data from a previous conversation
that no longer existed. This resulted in throwing error `TypeError
Unhandled Promise Rejection: null is not an object (evaluating
'e[Symbol.iterator]')` when users navigated from other screens to the
conversation view screen.

**Solution**

I added the `clearSelectedState` dispatch to the `created()` lifecycle
hook for cases where there's no `conversationId` from route props. This
ensures that the state is cleared before any child components are
mounted.


Fixes
https://chatwoot-p3.sentry.io/issues/5707937964/?project=4507182691975168

## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

**Steps to reproduce**
1. Navigate to chat list screen and open the console.
2. Open any chat, wait to load the messages and stay in conversation
view screen.
3. Then navigate to contact or any other view from primary sidebar. 
4. Then back to chat list view. 
5. Now you can see this error in console.

**Before**

https://www.loom.com/share/193aaf1d1926479982a192dfb06a8764?sid=3f9ee000-d6a0-47cc-a49f-0050d2c64bbf

**After**

https://www.loom.com/share/3d88cfd5e7744958bc5856dd601ee6c4?sid=0e07a5d0-e461-4a1a-914b-e49f669422f5

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2024-08-22 16:41:42 +05:30
dadd572f9d refactor: useKeyboardEvents composable (#9959)
This PR has the following changes

1. Fix tab styles issue caused by adding an additional wrapper for
getting an element ref on `ChatTypeTabs.vue`
2. Refactor `useKeyboardEvents` composable to not require an element
ref. It will use a local abort controller to abort any listener

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-08-22 16:40:55 +05:30
776579ba5b feat: enable disposable email check for account creation (#9989)
This PR disallows usage of disposable emails when creating an account

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2024-08-22 16:40:27 +05:30
4aa4e2549f feat: Update the design for automation (#10002)
This is the continuation of the design update for the settings screens.
In this PR, the automation page is updated with the latest design.

- Moved the row to a new component
- Migrated both components to composition API.
- Order by ID (Earlier this was order by updated_at which was
confusing).

| Light | Dark |
| --  | -- |
| <img width="1438" alt="Screenshot 2024-08-21 at 9 46 48 PM"
src="https://github.com/user-attachments/assets/89d96745-6556-48a1-82fa-a115325c24c0">
| <img width="1398" alt="Screenshot 2024-08-21 at 9 46 57 PM"
src="https://github.com/user-attachments/assets/5f1935ec-6d0e-4f82-b895-f47244764474">
|

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-08-22 16:22:54 +05:30
Muhsin KelothandGitHub eb6de74269 fix: OpenAPI label suggestion response payload (#10008) 2024-08-22 15:32:33 +05:30
d19a9c38d7 feat: Rewrite aiMixin to a composable (#9955)
This PR will replace the usage of aiMixin with the useAI composable.

Fixes https://linear.app/chatwoot/issue/CW-3443/rewrite-aimixin-mixin-to-a-composable

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-08-22 13:58:51 +05:30
Sivin VargheseandGitHub c63a6ed8ec feat: Rewrite agentMixin to a helper (#9940)
# Pull Request Template

## Description

This PR will replace the usage of `agentMixin`with the utility helpers
functions.

**Files updated**

1. dashboard/components/widgets/conversation/contextMenu/Index.vue
2. dashboard/components/widgets/conversation/ConversationHeader.vue
**(Not used)**
3. dashboard/routes/dashboard/commands/commandbar.vue
4. dashboard/routes/dashboard/conversation/ConversationAction.vue
5. dashboard/routes/dashboard/conversation/ConversationParticipant.vue

Fixes
https://linear.app/chatwoot/issue/CW-3442/rewrite-agentmixin-mixin-to-a-composable

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

**Test cases**

1. See agent list sorting based on availability, if agents are on the
same status, then sorted by name.
2. Test actions like assigning/unassigning agent from conversation
sidebar, CMD bar, Context menu.
3. Test actions like adding/removing participants from conversation
sidebar.
4. See agent list is generated properly, none value.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2024-08-22 13:02:11 +05:30
Muhsin KelothandGitHub 429d281501 fix: Handle OpenAI API errors (#9560) 2024-08-22 10:24:13 +05:30
Muhsin KelothandGitHub 6571baf211 fix: Delete attachments when removing them from Instagram in real-time (#9996) 2024-08-21 20:10:31 +05:30
44227de97e feat: Update the design for macros design page (#9999)
This is the continuation of the design update for settings page. This PR updates the design for the macros page.

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-08-21 18:27:53 +05:30
PranavandGitHub 77b718c22c fix: Remove user.permissions, resolve it from accounts (#9990)
Remove the `user.permissions` field and resolve the permissions directly
from the accounts array in the user. This change ensures that the cache
or previous values from the last active account don't affect the
permissions.

In this PR: 
- Remove user.permissions usage, replace it with getUserPermissions
method.
- Remove json.permissions from user.json.jbuilder
2024-08-21 11:36:26 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
04b67eb431 chore(deps): bump fugit from 1.9.0 to 1.11.1 (#9981)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-21 08:53:26 +05:30
Muhsin KelothandGitHub 74c689e4ad Merge branch 'develop' into chore/notification-logging 2024-08-08 13:20:49 +05:30
Muhsin KelothandGitHub ca2e7522a0 Merge branch 'develop' into chore/notification-logging 2024-07-31 21:03:00 +05:30
Shivam Mishra 41367739d1 feat: add more logging to notifications listener 2024-07-25 11:50:05 +05:30
Shivam Mishra fb0f4234d3 feat: allow addtional context 2024-07-25 11:49:47 +05:30
Shivam Mishra 99a54a9501 feat: allow addtional context 2024-07-25 11:46:24 +05:30
85 changed files with 1910 additions and 1394 deletions
+7 -7
View File
@@ -169,7 +169,7 @@ GEM
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.3)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
crack (1.0.0)
bigdecimal
@@ -230,7 +230,7 @@ GEM
ruby2_keywords
email_reply_trimmer (0.1.13)
erubi (1.13.0)
et-orbi (1.2.7)
et-orbi (1.2.11)
tzinfo
execjs (2.8.1)
facebook-messenger (2.0.1)
@@ -268,8 +268,8 @@ GEM
rake
flag_shih_tzu (0.3.23)
foreman (0.87.2)
fugit (1.9.0)
et-orbi (~> 1, >= 1.2.7)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
gapic-common (0.18.0)
faraday (>= 1.9, < 3.a)
@@ -467,7 +467,7 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.7)
minitest (5.24.1)
minitest (5.25.1)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.7.0)
@@ -480,7 +480,7 @@ GEM
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.12)
net-imap (0.4.14)
date
net-protocol
net-pop (0.1.2)
@@ -796,7 +796,7 @@ GEM
uniform_notifier (1.16.0)
uri (0.13.0)
uri_template (0.7.0)
valid_email2 (4.0.6)
valid_email2 (5.2.6)
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.4)
+2 -2
View File
@@ -33,10 +33,10 @@ class AccountBuilder
def validate_email
address = ValidEmail2::Address.new(@email)
if address.valid? # && !address.disposable?
if address.valid? && !address.disposable?
true
else
raise InvalidEmail.new(valid: address.valid?)
raise InvalidEmail.new({ valid: address.valid?, disposable: address.disposable? })
end
end
@@ -11,7 +11,12 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
end
def process_event
render json: { message: @hook.process_event(params[:event]) }
response = @hook.process_event(params[:event])
if response[:error]
render json: { error: response[:error] }, status: :unprocessable_entity
else
render json: { message: response[:message] }
end
end
def destroy
@@ -141,7 +141,7 @@ export default {
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, conversationListRef);
useKeyboardEvents(keyboardEvents);
return {
uiSettings,
@@ -19,7 +19,6 @@ const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
const resolveActionsRef = ref(null);
const arrowDownButtonRef = ref(null);
const isLoading = ref(false);
@@ -131,17 +130,14 @@ const keyboardEvents = {
},
};
useKeyboardEvents(keyboardEvents, resolveActionsRef);
useKeyboardEvents(keyboardEvents);
useEmitter(CMD_REOPEN_CONVERSATION, onCmdOpenConversation);
useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
</script>
<template>
<div
ref="resolveActionsRef"
class="relative flex items-center justify-end resolve-actions"
>
<div class="relative flex items-center justify-end resolve-actions">
<div class="button-group">
<woot-button
v-if="isOpen"
@@ -1,5 +1,4 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { getSidebarItems } from './config/default-sidebar';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
@@ -8,7 +7,10 @@ import { useRoute, useRouter } from 'dashboard/composables/route';
import PrimarySidebar from './sidebarComponents/Primary.vue';
import SecondarySidebar from './sidebarComponents/Secondary.vue';
import { routesWithPermissions } from '../../routes';
import { hasPermissions } from '../../helper/permissionsHelper';
import {
getUserPermissions,
hasPermissions,
} from '../../helper/permissionsHelper';
export default {
components: {
@@ -26,7 +28,6 @@ export default {
},
},
setup(props, { emit }) {
const sidebarRef = ref(null);
const route = useRoute();
const router = useRouter();
@@ -64,11 +65,10 @@ export default {
action: () => navigateToRoute('agent_list'),
},
};
useKeyboardEvents(keyboardEvents, sidebarRef);
useKeyboardEvents(keyboardEvents);
return {
toggleKeyShortcutModal,
sidebarRef,
};
},
data() {
@@ -113,7 +113,10 @@ export default {
return getSidebarItems(this.accountId);
},
primaryMenuItems() {
const userPermissions = this.currentUser.permissions;
const userPermissions = getUserPermissions(
this.currentUser,
this.accountId
);
const menuItems = this.sideMenuConfig.primaryMenu;
return menuItems.filter(menuItem => {
const isAvailableForTheUser = hasPermissions(
@@ -195,7 +198,7 @@ export default {
</script>
<template>
<aside ref="sidebarRef" class="flex h-full">
<aside class="flex h-full">
<PrimarySidebar
:logo-source="globalConfig.logoThumbnail"
:installation-name="globalConfig.installationName"
@@ -4,7 +4,10 @@ import SecondaryNavItem from './SecondaryNavItem.vue';
import AccountContext from './AccountContext.vue';
import { mapGetters } from 'vuex';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { hasPermissions } from '../../../helper/permissionsHelper';
import {
getUserPermissions,
hasPermissions,
} from '../../../helper/permissionsHelper';
import { routesWithPermissions } from '../../../routes';
export default {
@@ -59,7 +62,10 @@ export default {
accessibleMenuItems() {
const menuItemsFilteredByPermissions = this.menuConfig.menuItems.filter(
menuItem => {
const { permissions: userPermissions = [] } = this.currentUser;
const userPermissions = getUserPermissions(
this.currentUser,
this.accountId
);
return hasPermissions(
routesWithPermissions[menuItem.toStateName],
userPermissions
+14 -5
View File
@@ -1,7 +1,11 @@
<script setup>
import { useStoreGetters } from 'dashboard/composables/store';
import { computed } from 'vue';
import { hasPermissions } from '../helper/permissionsHelper';
import {
getUserPermissions,
hasPermissions,
} from '../helper/permissionsHelper';
const props = defineProps({
permissions: {
type: Array,
@@ -10,12 +14,17 @@ const props = defineProps({
});
const getters = useStoreGetters();
const user = getters.getCurrentUser.value;
const hasPermission = computed(() =>
hasPermissions(props.permissions, user.permissions)
);
const user = computed(() => getters.getCurrentUser.value);
const accountId = computed(() => getters.getCurrentAccountId.value);
const userPermissions = computed(() => {
return getUserPermissions(user.value, accountId.value);
});
const hasPermission = computed(() => {
return hasPermissions(props.permissions, userPermissions.value);
});
</script>
<!-- eslint-disable vue/no-root-v-if -->
<template>
<div v-if="hasPermission">
<slot />
@@ -4,9 +4,9 @@ import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import AICTAModal from './AICTAModal.vue';
import AIAssistanceModal from './AIAssistanceModal.vue';
import aiMixin from 'dashboard/mixins/aiMixin';
import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
@@ -16,17 +16,17 @@ export default {
AICTAModal,
AIAssistanceCTAButton,
},
mixins: [aiMixin],
setup(props, { emit }) {
const { uiSettings, updateUISettings } = useUISettings();
const { isAIIntegrationEnabled, draftMessage, recordAnalytics } = useAI();
const { isAdmin } = useAdmin();
const aiAssistanceButtonRef = ref(null);
const initialMessage = ref('');
const initializeMessage = draftMessage => {
initialMessage.value = draftMessage;
const initializeMessage = draftMsg => {
initialMessage.value = draftMsg;
};
const keyboardEvents = {
'$mod+KeyZ': {
@@ -39,15 +39,17 @@ export default {
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, aiAssistanceButtonRef);
useKeyboardEvents(keyboardEvents);
return {
uiSettings,
updateUISettings,
isAdmin,
aiAssistanceButtonRef,
initialMessage,
initializeMessage,
recordAnalytics,
isAIIntegrationEnabled,
draftMessage,
};
},
data: () => ({
@@ -118,7 +120,7 @@ export default {
</script>
<template>
<div ref="aiAssistanceButtonRef">
<div>
<div v-if="isAIIntegrationEnabled" class="relative">
<AIAssistanceCTAButton
v-if="shouldShowAIAssistCTAButton"
@@ -1,20 +1,27 @@
<script>
import { useAI } from 'dashboard/composables/useAI';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import AILoader from './AILoader.vue';
import aiMixin from 'dashboard/mixins/aiMixin';
export default {
components: {
AILoader,
},
mixins: [aiMixin, messageFormatterMixin],
mixins: [messageFormatterMixin],
props: {
aiOption: {
type: String,
required: true,
},
},
setup() {
const { draftMessage, processEvent, recordAnalytics } = useAI();
return {
draftMessage,
processEvent,
recordAnalytics,
};
},
data() {
return {
generatedContent: '',
@@ -3,16 +3,16 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import aiMixin from 'dashboard/mixins/aiMixin';
import { useAI } from 'dashboard/composables/useAI';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default {
mixins: [aiMixin],
setup() {
const { updateUISettings } = useUISettings();
const { recordAnalytics } = useAI();
const v$ = useVuelidate();
return { updateUISettings, v$ };
return { updateUISettings, v$, recordAnalytics };
},
data() {
return {
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed } from 'vue';
import { computed } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import wootConstants from 'dashboard/constants/globals';
@@ -16,8 +16,6 @@ const props = defineProps({
const emit = defineEmits(['chatTabChange']);
const chatTypeTabsRef = ref(null);
const activeTabIndex = computed(() => {
return props.items.findIndex(item => item.key === props.activeTab);
});
@@ -39,24 +37,23 @@ const keyboardEvents = {
},
},
};
useKeyboardEvents(keyboardEvents, chatTypeTabsRef);
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div ref="chatTypeTabsRef">
<woot-tabs
:index="activeTabIndex"
class="tab--chat-type py-0 px-4 w-full"
@change="onTabChange"
>
<woot-tabs-item
v-for="item in items"
:key="item.key"
:name="item.name"
:count="item.count"
/>
</woot-tabs>
</div>
<woot-tabs
:index="activeTabIndex"
class="w-full px-4 py-0 tab--chat-type"
@change="onTabChange"
>
<woot-tabs-item
v-for="item in items"
:key="item.key"
:name="item.name"
:count="item.count"
/>
</woot-tabs>
</template>
<style scoped lang="scss">
@@ -18,8 +18,6 @@ const props = defineProps({
const emit = defineEmits(['add', 'remove']);
const labelSelectorWrapRef = ref(null);
const { isAdmin } = useAdmin();
const showSearchDropdownLabel = ref(false);
@@ -57,15 +55,11 @@ const keyboardEvents = {
},
};
useKeyboardEvents(keyboardEvents, labelSelectorWrapRef);
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
ref="labelSelectorWrapRef"
v-on-clickaway="closeDropdownLabel"
class="relative leading-6"
>
<div v-on-clickaway="closeDropdownLabel" class="relative leading-6">
<AddLabel @add="toggleLabels" />
<woot-label
v-for="label in savedLabels"
@@ -1,5 +1,4 @@
<script>
import { ref, watchEffect, computed } from 'vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
@@ -117,15 +116,13 @@ export default {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
useUISettings();
const uploadRef = ref(null);
// TODO: This is really hacky, we need to replace the file picker component with
// a custom one, where the logic and the component markup is isolated.
// Once we have the custom component, we can remove the hacky logic below.
const uploadRefElem = computed(() => uploadRef.value?.$el);
const keyboardEvents = {
'Alt+KeyA': {
action: () => {
// TODO: This is really hacky, we need to replace the file picker component with
// a custom one, where the logic and the component markup is isolated.
// Once we have the custom component, we can remove the hacky logic below.
const uploadTriggerButton = document.querySelector(
'#conversationAttachment'
);
@@ -135,14 +132,11 @@ export default {
},
};
watchEffect(() => {
useKeyboardEvents(keyboardEvents, uploadRefElem);
});
useKeyboardEvents(keyboardEvents);
return {
setSignatureFlagForInbox,
fetchSignatureFlagFromUISettings,
uploadRef,
};
},
computed: {
@@ -267,7 +261,6 @@ export default {
@click="toggleEmojiPicker"
/>
<FileUpload
ref="uploadRef"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
input-id="conversationAttachment"
:size="4096 * 4096"
@@ -1,5 +1,4 @@
<script>
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
export default {
@@ -23,8 +22,6 @@ export default {
},
},
setup(props, { emit }) {
const replyTopRef = ref(null);
const setReplyMode = mode => {
emit('setReplyMode', mode);
};
@@ -44,12 +41,11 @@ export default {
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, replyTopRef);
useKeyboardEvents(keyboardEvents);
return {
handleReplyClick,
handleNoteClick,
replyTopRef,
};
},
computed: {
@@ -76,10 +72,7 @@ export default {
</script>
<template>
<div
ref="replyTopRef"
class="flex justify-between bg-black-50 dark:bg-slate-800"
>
<div class="flex justify-between bg-black-50 dark:bg-slate-800">
<div class="button-group">
<woot-button
variant="clear"
@@ -1,8 +1,6 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import agentMixin from '../../../mixins/agentMixin.js';
import BackButton from '../BackButton.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue';
@@ -24,7 +22,7 @@ export default {
SLACardLabel,
Linear,
},
mixins: [inboxMixin, agentMixin],
mixins: [inboxMixin],
props: {
chat: {
type: Object,
@@ -44,18 +42,12 @@ export default {
},
},
setup(props, { emit }) {
const conversationHeaderActionsRef = ref(null);
const keyboardEvents = {
'Alt+KeyO': {
action: () => emit('contactPanelToggle'),
},
};
useKeyboardEvents(keyboardEvents, conversationHeaderActionsRef);
return {
conversationHeaderActionsRef,
};
useKeyboardEvents(keyboardEvents);
},
computed: {
...mapGetters({
@@ -183,7 +175,6 @@ export default {
</div>
<div
ref="conversationHeaderActionsRef"
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" />
@@ -3,6 +3,7 @@ import { ref } from 'vue';
// composable
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
// components
import ReplyBox from './ReplyBox.vue';
@@ -15,7 +16,6 @@ import { mapGetters } from 'vuex';
// mixins
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import aiMixin from 'dashboard/mixins/aiMixin';
// utils
import { getTypingUsersText } from '../../../helper/commons';
@@ -40,7 +40,7 @@ export default {
Banner,
ConversationLabelSuggestion,
},
mixins: [inboxMixin, aiMixin],
mixins: [inboxMixin],
props: {
isContactPanelOpen: {
type: Boolean,
@@ -52,7 +52,6 @@ export default {
},
},
setup() {
const conversationFooterRef = ref(null);
const isPopOutReplyBox = ref(false);
const { isEnterprise } = useConfig();
@@ -70,14 +69,24 @@ export default {
},
};
useKeyboardEvents(keyboardEvents, conversationFooterRef);
useKeyboardEvents(keyboardEvents);
const {
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
} = useAI();
return {
isEnterprise,
conversationFooterRef,
isPopOutReplyBox,
closePopOutReplyBox,
showPopOutReplyBox,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
};
},
data() {
@@ -518,7 +527,6 @@ export default {
/>
</ul>
<div
ref="conversationFooterRef"
class="conversation-footer"
:class="{ 'modal-mask': isPopOutReplyBox }"
>
@@ -40,7 +40,6 @@ const onSelect = () => {
};
useKeyboardNavigableList({
elementRef: tagAgentsRef,
items,
onSelect,
adjustScroll,
@@ -35,7 +35,6 @@ const ALLOWED_FILE_TYPES = {
const MAX_ZOOM_LEVEL = 2;
const MIN_ZOOM_LEVEL = 1;
const galleryViewRef = ref(null);
const zoomScale = ref(1);
const activeAttachment = ref({});
const activeFileType = ref('');
@@ -202,7 +201,7 @@ const keyboardEvents = {
},
},
};
useKeyboardEvents(keyboardEvents, galleryViewRef);
useKeyboardEvents(keyboardEvents);
onMounted(() => {
setImageAndVideoSrc(props.attachment);
@@ -218,7 +217,6 @@ onMounted(() => {
:on-close="onClose"
>
<div
ref="galleryViewRef"
v-on-clickaway="onClose"
class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit] overflow-hidden"
@click="onClose"
@@ -1,9 +1,12 @@
<script>
import { mapGetters } from 'vuex';
import {
getSortedAgentsByAvailability,
getAgentsByUpdatedPresence,
} from 'dashboard/helper/agentHelper.js';
import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import agentMixin from 'dashboard/mixins/agentMixin';
import { mapGetters } from 'vuex';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
export default {
components: {
@@ -11,7 +14,6 @@ export default {
MenuItemWithSubmenu,
AgentLoadingPlaceholder,
},
mixins: [agentMixin],
props: {
chatId: {
type: Number,
@@ -112,13 +114,19 @@ export default {
labels: 'labels/getLabels',
teams: 'teams/getTeams',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
currentUser: 'getCurrentUser',
currentAccountId: 'getCurrentAccountId',
}),
filteredAgentOnAvailability() {
const agents = this.$store.getters[
'inboxAssignableAgents/getAssignableAgents'
](this.inboxId);
const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents);
const filteredAgents = this.sortedAgentsByAvailability(
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents,
this.currentUser,
this.currentAccountId
);
const filteredAgents = getSortedAgentsByAvailability(
agentsByUpdatedPresence
);
return filteredAgents;
@@ -2,7 +2,9 @@
// components
import WootButton from '../../../ui/WootButton.vue';
import Avatar from '../../Avatar.vue';
import aiMixin from 'dashboard/mixins/aiMixin';
// composables
import { useAI } from 'dashboard/composables/useAI';
// store & api
import { mapGetters } from 'vuex';
@@ -18,7 +20,6 @@ export default {
Avatar,
WootButton,
},
mixins: [aiMixin],
props: {
suggestedLabels: {
type: Array,
@@ -30,6 +31,11 @@ export default {
default: () => [],
},
},
setup() {
const { isAIIntegrationEnabled } = useAI();
return { isAIIntegrationEnabled };
},
data() {
return {
isDismissed: false,
@@ -41,7 +47,11 @@ export default {
...mapGetters({
allLabels: 'labels/getLabels',
currentAccountId: 'getCurrentAccountId',
currentChat: 'getSelectedChat',
}),
conversationId() {
return this.currentChat?.id;
},
labelTooltip() {
if (this.preparedLabels.length > 1) {
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.MULTIPLE_SUGGESTION');
@@ -39,7 +39,6 @@ const onSelect = () => {
};
useKeyboardNavigableList({
elementRef: mentionsListContainerRef,
items: computed(() => props.items),
onSelect,
adjustScroll,
@@ -0,0 +1,63 @@
import { allAgentsData } from 'dashboard/helper/specs/fixtures/agentFixtures';
export { allAgentsData };
export const formattedAgentsData = [
{
account_id: 0,
confirmed: true,
email: 'None',
id: 0,
name: 'None',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
];
@@ -0,0 +1,119 @@
import { useAI } from '../useAI';
import {
useStore,
useStoreGetters,
useMapGetter,
} from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from '../useI18n';
import OpenAPI from 'dashboard/api/integrations/openapi';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables');
vi.mock('../useI18n');
vi.mock('dashboard/api/integrations/openapi');
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
OPEN_AI_EVENTS: {
TEST_EVENT: 'open_ai_test_event',
},
}));
describe('useAI', () => {
const mockStore = {
dispatch: vi.fn(),
};
const mockGetters = {
'integrations/getUIFlags': { value: { isFetching: false } },
'draftMessages/get': { value: () => 'Draft message' },
};
beforeEach(() => {
vi.clearAllMocks();
useStore.mockReturnValue(mockStore);
useStoreGetters.mockReturnValue(mockGetters);
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [],
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
useTrack.mockReturnValue(vi.fn());
useI18n.mockReturnValue({ t: vi.fn() });
useAlert.mockReturnValue(vi.fn());
});
it('initializes computed properties correctly', async () => {
const { uiFlags, appIntegrations, currentChat, replyMode, draftMessage } =
useAI();
expect(uiFlags.value).toEqual({ isFetching: false });
expect(appIntegrations.value).toEqual([]);
expect(currentChat.value).toEqual({ id: '123' });
expect(replyMode.value).toBe('reply');
expect(draftMessage.value).toBe('Draft message');
});
it('fetches integrations if required', async () => {
const { fetchIntegrationsIfRequired } = useAI();
await fetchIntegrationsIfRequired();
expect(mockStore.dispatch).toHaveBeenCalledWith('integrations/get');
});
it('does not fetch integrations if already loaded', async () => {
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [{ id: 'openai' }],
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
const { fetchIntegrationsIfRequired } = useAI();
await fetchIntegrationsIfRequired();
expect(mockStore.dispatch).not.toHaveBeenCalled();
});
it('records analytics correctly', async () => {
const mockTrack = vi.fn();
useTrack.mockReturnValue(mockTrack);
const { recordAnalytics } = useAI();
await recordAnalytics('TEST_EVENT', { data: 'test' });
expect(mockTrack).toHaveBeenCalledWith('open_ai_test_event', {
type: 'TEST_EVENT',
data: 'test',
});
});
it('fetches label suggestions', async () => {
OpenAPI.processEvent.mockResolvedValue({
data: { message: 'label1, label2' },
});
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [
{ id: 'openai', hooks: [{ id: 'hook1' }] },
],
getSelectedChat: { id: '123' },
};
return { value: mockValues[getter] };
});
const { fetchLabelSuggestions } = useAI();
const result = await fetchLabelSuggestions();
expect(OpenAPI.processEvent).toHaveBeenCalledWith({
type: 'label_suggestion',
hookId: 'hook1',
conversationId: '123',
});
expect(result).toEqual(['label1', 'label2']);
});
});
@@ -0,0 +1,91 @@
import { ref } from 'vue';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAgentsList } from '../useAgentsList';
import { useMapGetter } from 'dashboard/composables/store';
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
import * as agentHelper from 'dashboard/helper/agentHelper';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/helper/agentHelper');
const mockUseMapGetter = (overrides = {}) => {
const defaultGetters = {
getCurrentUser: ref(allAgentsData[0]),
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
getCurrentAccountId: ref(1),
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
};
const mergedGetters = { ...defaultGetters, ...overrides };
useMapGetter.mockImplementation(getter => mergedGetters[getter]);
};
describe('useAgentsList', () => {
beforeEach(() => {
vi.clearAllMocks();
agentHelper.getAgentsByUpdatedPresence.mockImplementation(agents => agents);
agentHelper.getSortedAgentsByAvailability.mockReturnValue(
formattedAgentsData.slice(1)
);
agentHelper.getCombinedAgents.mockImplementation(
(agents, includeNone, isAgentSelected) => {
if (includeNone && isAgentSelected) {
return [agentHelper.createNoneAgent, ...agents];
}
return agents;
}
);
mockUseMapGetter();
});
it('returns agentsList and assignableAgents', () => {
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual(allAgentsData);
expect(agentsList.value).toEqual([
agentHelper.createNoneAgent,
...formattedAgentsData.slice(1),
]);
});
it('includes None agent when includeNoneAgent is true', () => {
const { agentsList } = useAgentsList(true);
expect(agentsList.value[0]).toEqual(agentHelper.createNoneAgent);
expect(agentsList.value.length).toBe(formattedAgentsData.length);
});
it('excludes None agent when includeNoneAgent is false', () => {
const { agentsList } = useAgentsList(false);
expect(agentsList.value[0]).not.toEqual(agentHelper.createNoneAgent);
expect(agentsList.value.length).toBe(formattedAgentsData.length - 1);
});
it('handles empty assignable agents', () => {
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
});
agentHelper.getSortedAgentsByAvailability.mockReturnValue([]);
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual([]);
expect(agentsList.value).toEqual([agentHelper.createNoneAgent]);
});
it('handles missing inbox_id', () => {
mockUseMapGetter({
getSelectedChat: ref({ meta: { assignee: true } }),
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
});
agentHelper.getSortedAgentsByAvailability.mockReturnValue([]);
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual([]);
expect(agentsList.value).toEqual([agentHelper.createNoneAgent]);
});
});
@@ -0,0 +1,56 @@
import { useIntegrationHook } from '../useIntegrationHook';
import { useMapGetter } from 'dashboard/composables/store';
import { nextTick } from 'vue';
vi.mock('dashboard/composables/store');
describe('useIntegrationHook', () => {
let integrationGetter;
beforeEach(() => {
integrationGetter = vi.fn();
useMapGetter.mockReturnValue({ value: integrationGetter });
});
it('should return the correct computed properties', async () => {
const mockIntegration = {
id: 1,
hook_type: 'inbox',
hooks: ['hook1', 'hook2'],
allow_multiple_hooks: true,
};
integrationGetter.mockReturnValue(mockIntegration);
const hook = useIntegrationHook(1);
await nextTick();
expect(hook.integration.value).toEqual(mockIntegration);
expect(hook.integrationType.value).toBe('multiple');
expect(hook.isIntegrationMultiple.value).toBe(true);
expect(hook.isIntegrationSingle.value).toBe(false);
expect(hook.isHookTypeInbox.value).toBe(true);
expect(hook.hasConnectedHooks.value).toBe(true);
});
it('should handle single integration type correctly', async () => {
const mockIntegration = {
id: 2,
hook_type: 'channel',
hooks: [],
allow_multiple_hooks: false,
};
integrationGetter.mockReturnValue(mockIntegration);
const hook = useIntegrationHook(2);
await nextTick();
expect(hook.integration.value).toEqual(mockIntegration);
expect(hook.integrationType.value).toBe('single');
expect(hook.isIntegrationMultiple.value).toBe(false);
expect(hook.isIntegrationSingle.value).toBe(true);
expect(hook.isHookTypeInbox.value).toBe(false);
expect(hook.hasConnectedHooks.value).toBe(false);
});
});
@@ -1,4 +1,3 @@
import { unref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
describe('useKeyboardEvents', () => {
@@ -11,15 +10,13 @@ describe('useKeyboardEvents', () => {
});
it('should set up listeners on mount and remove them on unmount', async () => {
const el = document.createElement('div');
const elRef = unref({ value: el });
const events = {
'ALT+KeyL': () => {},
};
const mountedMock = vi.fn();
const unmountedMock = vi.fn();
useKeyboardEvents(events, elRef);
useKeyboardEvents(events);
mountedMock();
unmountedMock();
@@ -9,7 +9,6 @@ vi.mock('../useKeyboardEvents', () => ({
}));
describe('useKeyboardNavigableList', () => {
let elementRef;
let items;
let onSelect;
let adjustScroll;
@@ -18,7 +17,6 @@ describe('useKeyboardNavigableList', () => {
const createMockEvent = () => ({ preventDefault: vi.fn() });
beforeEach(() => {
elementRef = ref(null);
items = ref(['item1', 'item2', 'item3']);
onSelect = vi.fn();
adjustScroll = vi.fn();
@@ -28,7 +26,6 @@ describe('useKeyboardNavigableList', () => {
it('should return moveSelectionUp and moveSelectionDown functions', () => {
const result = useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -43,7 +40,6 @@ describe('useKeyboardNavigableList', () => {
it('should move selection up correctly', () => {
const { moveSelectionUp } = useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -65,7 +61,6 @@ describe('useKeyboardNavigableList', () => {
it('should move selection down correctly', () => {
const { moveSelectionDown } = useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -87,7 +82,6 @@ describe('useKeyboardNavigableList', () => {
it('should call adjustScroll after moving selection', () => {
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -103,7 +97,6 @@ describe('useKeyboardNavigableList', () => {
it('should include Enter key handler when onSelect is provided', () => {
useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -118,7 +111,6 @@ describe('useKeyboardNavigableList', () => {
it('should not include Enter key handler when onSelect is not provided', () => {
useKeyboardNavigableList({
elementRef,
items,
adjustScroll,
selectedIndex,
@@ -131,7 +123,6 @@ describe('useKeyboardNavigableList', () => {
it('should not trigger onSelect when items are empty', () => {
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
elementRef,
items: ref([]),
onSelect,
adjustScroll,
@@ -145,23 +136,18 @@ describe('useKeyboardNavigableList', () => {
it('should call useKeyboardEvents with correct parameters', () => {
useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
selectedIndex,
});
expect(useKeyboardEvents).toHaveBeenCalledWith(
expect.any(Object),
elementRef
);
expect(useKeyboardEvents).toHaveBeenCalledWith(expect.any(Object));
});
// Keyboard event handlers
it('should handle ArrowUp key', () => {
useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -178,7 +164,6 @@ describe('useKeyboardNavigableList', () => {
it('should handle Control+KeyP', () => {
useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -195,7 +180,6 @@ describe('useKeyboardNavigableList', () => {
it('should handle ArrowDown key', () => {
useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -212,7 +196,6 @@ describe('useKeyboardNavigableList', () => {
it('should handle Control+KeyN', () => {
useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -229,7 +212,6 @@ describe('useKeyboardNavigableList', () => {
it('should handle Enter key when onSelect is provided', () => {
useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -245,7 +227,6 @@ describe('useKeyboardNavigableList', () => {
it('should not have Enter key handler when onSelect is not provided', () => {
useKeyboardNavigableList({
elementRef,
items,
adjustScroll,
selectedIndex,
@@ -257,7 +238,6 @@ describe('useKeyboardNavigableList', () => {
it('should set allowOnFocusedInput to true for all key handlers', () => {
useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -0,0 +1,204 @@
import { computed, onMounted } from 'vue';
import {
useStore,
useStoreGetters,
useMapGetter,
} from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from './useI18n';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import OpenAPI from 'dashboard/api/integrations/openapi';
/**
* Cleans and normalizes a list of labels.
* @param {string} labels - A comma-separated string of labels.
* @returns {string[]} An array of cleaned and unique labels.
*/
const cleanLabels = labels => {
return labels
.toLowerCase() // Set it to lowercase
.split(',') // split the string into an array
.filter(label => label.trim()) // remove any empty strings
.map(label => label.trim()) // trim the words
.filter((label, index, self) => self.indexOf(label) === index);
};
/**
* A composable function for AI-related operations in the dashboard.
* @returns {Object} An object containing AI-related methods and computed properties.
*/
export function useAI() {
const store = useStore();
const getters = useStoreGetters();
const track = useTrack();
const { t } = useI18n();
/**
* Computed property for UI flags.
* @type {import('vue').ComputedRef<Object>}
*/
const uiFlags = computed(() => getters['integrations/getUIFlags'].value);
const appIntegrations = useMapGetter('integrations/getAppIntegrations');
const currentChat = useMapGetter('getSelectedChat');
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
/**
* Computed property for the AI integration.
* @type {import('vue').ComputedRef<Object|undefined>}
*/
const aiIntegration = computed(
() =>
appIntegrations.value.find(
integration => integration.id === 'openai' && !!integration.hooks.length
)?.hooks[0]
);
/**
* Computed property to check if AI integration is enabled.
* @type {import('vue').ComputedRef<boolean>}
*/
const isAIIntegrationEnabled = computed(() => !!aiIntegration.value);
/**
* Computed property to check if label suggestion feature is enabled.
* @type {import('vue').ComputedRef<boolean>}
*/
const isLabelSuggestionFeatureEnabled = computed(() => {
if (aiIntegration.value) {
const { settings = {} } = aiIntegration.value || {};
return settings.label_suggestion;
}
return false;
});
/**
* Computed property to check if app integrations are being fetched.
* @type {import('vue').ComputedRef<boolean>}
*/
const isFetchingAppIntegrations = computed(() => uiFlags.value.isFetching);
/**
* Computed property for the hook ID.
* @type {import('vue').ComputedRef<string|undefined>}
*/
const hookId = computed(() => aiIntegration.value?.id);
/**
* Computed property for the conversation ID.
* @type {import('vue').ComputedRef<string|undefined>}
*/
const conversationId = computed(() => currentChat.value?.id);
/**
* Computed property for the draft key.
* @type {import('vue').ComputedRef<string>}
*/
const draftKey = computed(
() => `draft-${conversationId.value}-${replyMode.value}`
);
/**
* Computed property for the draft message.
* @type {import('vue').ComputedRef<string>}
*/
const draftMessage = computed(() =>
getters['draftMessages/get'].value(draftKey.value)
);
/**
* Fetches integrations if they haven't been loaded yet.
* @returns {Promise<void>}
*/
const fetchIntegrationsIfRequired = async () => {
if (!appIntegrations.value.length) {
await store.dispatch('integrations/get');
}
};
/**
* Records analytics for AI-related events.
* @param {string} type - The type of event.
* @param {Object} payload - Additional data for the event.
* @returns {Promise<void>}
*/
const recordAnalytics = async (type, payload) => {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
track(event, {
type,
...payload,
});
}
};
/**
* Fetches label suggestions for the current conversation.
* @returns {Promise<string[]>} An array of suggested labels.
*/
const fetchLabelSuggestions = async () => {
if (!conversationId.value) return [];
try {
const result = await OpenAPI.processEvent({
type: 'label_suggestion',
hookId: hookId.value,
conversationId: conversationId.value,
});
const {
data: { message: labels },
} = result;
return cleanLabels(labels);
} catch {
return [];
}
};
/**
* Processes an AI event, such as rephrasing content.
* @param {string} [type='rephrase'] - The type of AI event to process.
* @returns {Promise<string>} The generated message or an empty string if an error occurs.
*/
const processEvent = async (type = 'rephrase') => {
try {
const result = await OpenAPI.processEvent({
hookId: hookId.value,
type,
content: draftMessage.value,
conversationId: conversationId.value,
});
const {
data: { message: generatedMessage },
} = result;
return generatedMessage;
} catch (error) {
const errorData = error.response.data.error;
const errorMessage =
errorData?.error?.message ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
return '';
}
};
onMounted(() => {
fetchIntegrationsIfRequired();
});
return {
draftMessage,
uiFlags,
appIntegrations,
currentChat,
replyMode,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
isFetchingAppIntegrations,
fetchIntegrationsIfRequired,
recordAnalytics,
fetchLabelSuggestions,
processEvent,
};
}
@@ -0,0 +1,57 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import {
getAgentsByUpdatedPresence,
getSortedAgentsByAvailability,
getCombinedAgents,
} from 'dashboard/helper/agentHelper';
/**
* A composable function that provides a list of agents for assignment.
*
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
* @returns {Object} An object containing the agents list and assignable agents.
*/
export function useAgentsList(includeNoneAgent = true) {
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
const currentAccountId = useMapGetter('getCurrentAccountId');
const assignable = useMapGetter('inboxAssignableAgents/getAssignableAgents');
const inboxId = computed(() => currentChat.value?.inbox_id);
const isAgentSelected = computed(() => currentChat.value?.meta?.assignee);
/**
* @type {import('vue').ComputedRef<Array>}
*/
const assignableAgents = computed(() => {
return inboxId.value ? assignable.value(inboxId.value) : [];
});
/**
* @type {import('vue').ComputedRef<Array>}
*/
const agentsList = computed(() => {
const agents = assignableAgents.value || [];
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents,
currentUser.value,
currentAccountId.value
);
const filteredAgentsByAvailability = getSortedAgentsByAvailability(
agentsByUpdatedPresence
);
return getCombinedAgents(
filteredAgentsByAvailability,
includeNoneAgent,
isAgentSelected.value
);
});
return {
agentsList,
assignableAgents,
};
}
@@ -0,0 +1,68 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
/**
* Composable for managing integration hooks
* @param {string|number} integrationId - The ID of the integration
* @returns {Object} An object containing computed properties for the integration
*/
export const useIntegrationHook = integrationId => {
const integrationGetter = useMapGetter('integrations/getIntegration');
/**
* The integration object
* @type {import('vue').ComputedRef<Object>}
*/
const integration = computed(() => {
return integrationGetter.value(integrationId);
});
/**
* Whether the integration hook type is 'inbox'
* @type {import('vue').ComputedRef<boolean>}
*/
const isHookTypeInbox = computed(() => {
return integration.value.hook_type === 'inbox';
});
/**
* Whether the integration has any connected hooks
* @type {import('vue').ComputedRef<boolean>}
*/
const hasConnectedHooks = computed(() => {
return !!integration.value.hooks.length;
});
/**
* The type of integration: 'multiple' or 'single'
* @type {import('vue').ComputedRef<string>}
*/
const integrationType = computed(() => {
return integration.value.allow_multiple_hooks ? 'multiple' : 'single';
});
/**
* Whether the integration allows multiple hooks
* @type {import('vue').ComputedRef<boolean>}
*/
const isIntegrationMultiple = computed(() => {
return integrationType.value === 'multiple';
});
/**
* Whether the integration allows only a single hook
* @type {import('vue').ComputedRef<boolean>}
*/
const isIntegrationSingle = computed(() => {
return integrationType.value === 'single';
});
return {
integration,
integrationType,
isIntegrationMultiple,
isIntegrationSingle,
isHookTypeInbox,
hasConnectedHooks,
};
};
@@ -1,4 +1,3 @@
import { onMounted, onBeforeUnmount, unref } from 'vue';
import {
isActiveElementTypeable,
isEscape,
@@ -7,8 +6,7 @@ import {
} from 'shared/helpers/KeyboardHelpers';
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
import { createKeybindingsHandler } from 'tinykeys';
const keyboardListenerMap = new WeakMap();
import { onUnmounted, onMounted } from 'vue';
/**
* Determines if the keyboard event should be ignored based on the element type and handler settings.
@@ -69,49 +67,24 @@ async function wrapEventsInKeybindingsHandler(events) {
return wrappedEvents;
}
/**
* Sets up keyboard event listeners on the specified element.
* @param {Element} root - The DOM element to attach listeners to.
* @param {Object} events - The events to listen for.
*/
const setupListeners = (root, events) => {
if (root instanceof Element && events) {
const keydownHandler = createKeybindingsHandler(events);
document.addEventListener('keydown', keydownHandler);
keyboardListenerMap.set(root, keydownHandler);
}
};
/**
* Removes keyboard event listeners from the specified element.
* @param {Element} root - The DOM element to remove listeners from.
*/
const removeListeners = root => {
// In the future, let's use the abort controller to remove the listeners
// https://caniuse.com/abortcontroller
if (root instanceof Element) {
const handlerToRemove = keyboardListenerMap.get(root);
document.removeEventListener('keydown', handlerToRemove);
keyboardListenerMap.delete(root);
}
};
/**
* Vue composable to handle keyboard events with support for different keyboard layouts.
* @param {Object} keyboardEvents - The keyboard events to handle.
* @param {ref} elRef - A Vue ref to the element to attach the keyboard events to.
*/
export function useKeyboardEvents(keyboardEvents, elRef = null) {
export async function useKeyboardEvents(keyboardEvents) {
let abortController = new AbortController();
onMounted(async () => {
const el = unref(elRef);
const getKeyboardEvents = () => keyboardEvents || null;
const events = getKeyboardEvents();
const wrappedEvents = await wrapEventsInKeybindingsHandler(events);
setupListeners(el, wrappedEvents);
if (!keyboardEvents) return;
const wrappedEvents = await wrapEventsInKeybindingsHandler(keyboardEvents);
const keydownHandler = createKeybindingsHandler(wrappedEvents);
document.addEventListener('keydown', keydownHandler, {
signal: abortController.signal,
});
});
onBeforeUnmount(() => {
const el = unref(elRef);
removeListeners(el);
onUnmounted(() => {
abortController.abort();
});
}
@@ -84,7 +84,6 @@ const updateSelectionIndex = (currentIndex, itemsLength, direction) => {
* }} An object containing functions to move the selection up and down.
*/
export function useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -109,7 +108,7 @@ export function useKeyboardNavigableList({
items
);
useKeyboardEvents(keyboardEvents, elementRef);
useKeyboardEvents(keyboardEvents);
return {
moveSelectionUp,
@@ -0,0 +1,83 @@
/**
* Default agent object representing 'None'
* @type {Object}
*/
export const createNoneAgent = {
confirmed: true,
name: 'None',
id: 0,
role: 'agent',
account_id: 0,
email: 'None',
};
/**
* Filters and sorts agents by availability status
* @param {Array} agents - List of agents
* @param {string} availability - Availability status to filter by
* @returns {Array} Filtered and sorted list of agents
*/
export const getAgentsByAvailability = (agents, availability) => {
return agents
.filter(agent => agent.availability_status === availability)
.sort((a, b) => a.name.localeCompare(b.name));
};
/**
* Sorts agents by availability status: online, busy, then offline
* @param {Array} agents - List of agents
* @returns {Array} Sorted list of agents
*/
export const getSortedAgentsByAvailability = agents => {
const onlineAgents = getAgentsByAvailability(agents, 'online');
const busyAgents = getAgentsByAvailability(agents, 'busy');
const offlineAgents = getAgentsByAvailability(agents, 'offline');
const filteredAgents = [...onlineAgents, ...busyAgents, ...offlineAgents];
return filteredAgents;
};
/**
* Updates the availability status of the current user based on the current account
* @param {Array} agents - List of agents
* @param {Object} currentUser - Current user object
* @param {number} currentAccountId - ID of the current account
* @returns {Array} Updated list of agents with dynamic presence
*/
// Here we are updating the availability status of the current user dynamically
// based on the current account availability status
export const getAgentsByUpdatedPresence = (
agents,
currentUser,
currentAccountId
) => {
const agentsWithDynamicPresenceUpdate = agents.map(item =>
item.id === currentUser.id
? {
...item,
availability_status: currentUser.accounts.find(
account => account.id === currentAccountId
).availability_status,
}
: item
);
return agentsWithDynamicPresenceUpdate;
};
/**
* Combines the filtered agents with the 'None' agent option if applicable.
*
* @param {Array} filteredAgentsByAvailability - The list of agents sorted by availability.
* @param {boolean} includeNoneAgent - Whether to include the 'None' agent option.
* @param {boolean} isAgentSelected - Whether an agent is currently selected.
* @returns {Array} The combined list of agents, potentially including the 'None' agent.
*/
export const getCombinedAgents = (
filteredAgentsByAvailability,
includeNoneAgent,
isAgentSelected
) => {
return [
...(includeNoneAgent && isAgentSelected ? [createNoneAgent] : []),
...filteredAgentsByAvailability,
];
};
@@ -11,6 +11,7 @@ const FEATURE_HELP_URLS = {
help_center: 'https://chwt.app/hc/help-center',
integrations: 'https://chwt.app/hc/integrations',
labels: 'https://chwt.app/hc/labels',
macros: 'https://chwt.app/hc/macros',
message_reply_to: 'https://chwt.app/hc/reply-to',
reports: 'https://chwt.app/hc/reports',
sla: 'https://chwt.app/hc/sla',
@@ -7,6 +7,15 @@ export const hasPermissions = (
);
};
export const getCurrentAccount = ({ accounts } = {}, accountId = null) => {
return accounts.find(account => Number(account.id) === Number(accountId));
};
export const getUserPermissions = (user, accountId) => {
const currentAccount = getCurrentAccount(user, accountId) || {};
return currentAccount.permissions || [];
};
const isPermissionsPresentInRoute = route =>
route.meta && route.meta.permissions;
@@ -1,9 +1,8 @@
import { hasPermissions } from './permissionsHelper';
// eslint-disable-next-line default-param-last
export const getCurrentAccount = ({ accounts } = {}, accountId) => {
return accounts.find(account => account.id === accountId);
};
import {
hasPermissions,
getUserPermissions,
getCurrentAccount,
} from './permissionsHelper';
export const routeIsAccessibleFor = (route, userPermissions = []) => {
const { meta: { permissions: routePermissions = [] } = {} } = route;
@@ -19,7 +18,9 @@ const validateActiveAccountRoutes = (to, user) => {
return accountDashboardURL;
}
const isAccessible = routeIsAccessibleFor(to, user.permissions);
const userPermissions = getUserPermissions(user, to.params.accountId);
const isAccessible = routeIsAccessibleFor(to, userPermissions);
// If the route is not accessible for the user, return to dashboard screen
return isAccessible ? null : accountDashboardURL;
};
@@ -0,0 +1,131 @@
import {
getAgentsByAvailability,
getSortedAgentsByAvailability,
getAgentsByUpdatedPresence,
getCombinedAgents,
createNoneAgent,
} from '../agentHelper';
import {
allAgentsData,
onlineAgentsData,
busyAgentsData,
offlineAgentsData,
sortedByAvailability,
formattedAgentsByPresenceOnline,
formattedAgentsByPresenceOffline,
} from 'dashboard/helper/specs/fixtures/agentFixtures';
describe('agentHelper', () => {
describe('getAgentsByAvailability', () => {
it('returns agents by availability', () => {
expect(getAgentsByAvailability(allAgentsData, 'online')).toEqual(
onlineAgentsData
);
expect(getAgentsByAvailability(allAgentsData, 'busy')).toEqual(
busyAgentsData
);
expect(getAgentsByAvailability(allAgentsData, 'offline')).toEqual(
offlineAgentsData
);
});
});
describe('getSortedAgentsByAvailability', () => {
it('returns sorted agents by availability', () => {
expect(getSortedAgentsByAvailability(allAgentsData)).toEqual(
sortedByAvailability
);
});
it('returns an empty array when given an empty input', () => {
expect(getSortedAgentsByAvailability([])).toEqual([]);
});
it('maintains the order of agents with the same availability status', () => {
const result = getSortedAgentsByAvailability(allAgentsData);
expect(result[2].name).toBe('Honey Bee');
expect(result[3].name).toBe('Samuel Keta');
});
});
describe('getAgentsByUpdatedPresence', () => {
it('returns agents with updated presence', () => {
const currentUser = {
id: 1,
accounts: [{ id: 1, availability_status: 'offline' }],
};
const currentAccountId = 1;
expect(
getAgentsByUpdatedPresence(
formattedAgentsByPresenceOnline,
currentUser,
currentAccountId
)
).toEqual(formattedAgentsByPresenceOffline);
});
it('does not modify other agents presence', () => {
const currentUser = {
id: 2,
accounts: [{ id: 1, availability_status: 'offline' }],
};
const currentAccountId = 1;
expect(
getAgentsByUpdatedPresence(
formattedAgentsByPresenceOnline,
currentUser,
currentAccountId
)
).toEqual(formattedAgentsByPresenceOnline);
});
it('handles empty agent list', () => {
const currentUser = {
id: 1,
accounts: [{ id: 1, availability_status: 'offline' }],
};
const currentAccountId = 1;
expect(
getAgentsByUpdatedPresence([], currentUser, currentAccountId)
).toEqual([]);
});
});
describe('getCombinedAgents', () => {
it('includes None agent when includeNoneAgent is true and isAgentSelected is true', () => {
const result = getCombinedAgents(sortedByAvailability, true, true);
expect(result).toEqual([createNoneAgent, ...sortedByAvailability]);
expect(result.length).toBe(sortedByAvailability.length + 1);
expect(result[0]).toEqual(createNoneAgent);
});
it('excludes None agent when includeNoneAgent is false', () => {
const result = getCombinedAgents(sortedByAvailability, false, true);
expect(result).toEqual(sortedByAvailability);
expect(result.length).toBe(sortedByAvailability.length);
expect(result[0]).not.toEqual(createNoneAgent);
});
it('excludes None agent when isAgentSelected is false', () => {
const result = getCombinedAgents(sortedByAvailability, true, false);
expect(result).toEqual(sortedByAvailability);
expect(result.length).toBe(sortedByAvailability.length);
expect(result[0]).not.toEqual(createNoneAgent);
});
it('returns only filtered agents when both includeNoneAgent and isAgentSelected are false', () => {
const result = getCombinedAgents(sortedByAvailability, false, false);
expect(result).toEqual(sortedByAvailability);
expect(result.length).toBe(sortedByAvailability.length);
});
it('handles empty filteredAgentsByAvailability array', () => {
const result = getCombinedAgents([], true, true);
expect(result).toEqual([createNoneAgent]);
expect(result.length).toBe(1);
});
});
});
@@ -0,0 +1,184 @@
export const allAgentsData = [
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
];
export const onlineAgentsData = [
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
];
export const busyAgentsData = [
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
];
export const offlineAgentsData = [
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
];
export const sortedByAvailability = [
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
];
export const formattedAgentsByPresenceOnline = [
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abr@chatwoot.com',
id: 1,
name: 'Abraham Keta',
role: 'agent',
},
];
export const formattedAgentsByPresenceOffline = [
{
account_id: 1,
availability_status: 'offline',
available_name: 'Abraham',
confirmed: true,
email: 'abr@chatwoot.com',
id: 1,
name: 'Abraham Keta',
role: 'agent',
},
];
@@ -1,8 +1,31 @@
import {
buildPermissionsFromRouter,
getCurrentAccount,
getUserPermissions,
hasPermissions,
} from '../permissionsHelper';
describe('#getCurrentAccount', () => {
it('should return the current account', () => {
expect(getCurrentAccount({ accounts: [{ id: 1 }] }, 1)).toEqual({ id: 1 });
expect(getCurrentAccount({ accounts: [] }, 1)).toEqual(undefined);
});
});
describe('#getUserPermissions', () => {
it('should return the correct permissions', () => {
const user = {
accounts: [
{ id: 1, permissions: ['conversations_manage'] },
{ id: 3, permissions: ['contacts_manage'] },
],
};
expect(getUserPermissions(user, 1)).toEqual(['conversations_manage']);
expect(getUserPermissions(user, '3')).toEqual(['contacts_manage']);
expect(getUserPermissions(user, 2)).toEqual([]);
});
});
describe('hasPermissions', () => {
it('returns true if permission is present', () => {
expect(
@@ -1,19 +1,11 @@
import {
getConversationDashboardRoute,
getCurrentAccount,
isAConversationRoute,
routeIsAccessibleFor,
validateLoggedInRoutes,
isAInboxViewRoute,
} from '../routeHelpers';
describe('#getCurrentAccount', () => {
it('should return the current account', () => {
expect(getCurrentAccount({ accounts: [{ id: 1 }] }, 1)).toEqual({ id: 1 });
expect(getCurrentAccount({ accounts: [] }, 1)).toEqual(undefined);
});
});
describe('#routeIsAccessibleFor', () => {
it('should return the correct access', () => {
let route = { meta: { permissions: ['administrator'] } };
@@ -1,9 +1,10 @@
{
"AUTOMATION": {
"HEADER": "Automations",
"HEADER": "Automation",
"DESCRIPTION": "Automation can replace and streamline existing processes that require manual effort, such as adding labels and assigning conversations to the most suitable agent. This allows the team to focus on their strengths while reducing time spent on routine tasks.",
"LEARN_MORE": "Learn more about automation",
"HEADER_BTN_TXT": "Add Automation Rule",
"LOADING": "Fetching automation rules",
"SIDEBAR_TXT": "<p><b>Automation Rules</b> <p>Automation can replace and automate existing processes that require manual effort. You can do many things with automation, including adding labels and assigning conversation to the best agent. So the team focuses on what they do best and spends more little time on manual tasks.</p>",
"ADD": {
"TITLE": "Add Automation Rule",
"SUBMIT": "Create",
@@ -39,7 +40,12 @@
}
},
"LIST": {
"TABLE_HEADER": ["Name", "Description", "Active", "Created on"],
"TABLE_HEADER": [
"Name",
"Description",
"Active",
"Created on"
],
"404": "No automation rules found"
},
"DELETE": {
@@ -1,10 +1,11 @@
{
"MACROS": {
"HEADER": "Macros",
"DESCRIPTION": "A macro is a set of saved actions that help customer service agents easily complete tasks. The agents can define a set of actions like tagging a conversation with a label, sending an email transcript, updating a custom attribute, etc., and they can run these actions in a single click.",
"LEARN_MORE": "Learn more about macros",
"HEADER_BTN_TXT": "Add a new macro",
"HEADER_BTN_TXT_SAVE": "Save macro",
"LOADING": "Fetching macros",
"SIDEBAR_TXT": "<p><b>Macros</b><p>A macro is a set of saved actions that help customer service agents easily complete tasks. The agents can define a set of actions like tagging a conversation with a label, sending an email transcript, updating a custom attribute, etc., and they can run these actions in a single click. When the agents run the macro, the actions would be performed sequentially in the order they are defined. Macros improve productivity and increase consistency in actions. </p><p>A macro can be helpful in 2 ways. </p><p><b>As an agent assist:</b> If an agent performs a set of actions multiple times, they can save it as a macro and execute all the actions together using a single click.</p><p><b>As an option to onboard a team member:</b> Every agent has to perform many different checks/actions during each conversation. Onboarding a new support team member will be easy if pre-defined macros are available on the account. Instead of describing each step in detail, the manager/team lead can point to the macros used in different scenarios.</p>",
"ERROR": "Something went wrong. Please try again",
"ORDER_INFO": "Macros will run in the order you add your actions. You can rearrange them by dragging them by the handle beside each node.",
"ADD": {
@@ -24,7 +25,12 @@
}
},
"LIST": {
"TABLE_HEADER": ["Name", "Created by", "Last updated by", "Visibility"],
"TABLE_HEADER": [
"Name",
"Created by",
"Last updated by",
"Visibility"
],
"404": "No macros found"
},
"DELETE": {
@@ -1,69 +0,0 @@
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
currentAccountId: 'getCurrentAccountId',
}),
assignableAgents() {
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.inboxId
);
},
isAgentSelected() {
return this.currentChat?.meta?.assignee;
},
createNoneAgent() {
return {
confirmed: true,
name: 'None',
id: 0,
role: 'agent',
account_id: 0,
email: 'None',
};
},
agentsList() {
const agents = this.assignableAgents || [];
const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents);
const none = this.createNoneAgent;
const filteredAgentsByAvailability = this.sortedAgentsByAvailability(
agentsByUpdatedPresence
);
const filteredAgents = [
...(this.isAgentSelected ? [none] : []),
...filteredAgentsByAvailability,
];
return filteredAgents;
},
},
methods: {
getAgentsByAvailability(agents, availability) {
return agents
.filter(agent => agent.availability_status === availability)
.sort((a, b) => a.name.localeCompare(b.name));
},
sortedAgentsByAvailability(agents) {
const onlineAgents = this.getAgentsByAvailability(agents, 'online');
const busyAgents = this.getAgentsByAvailability(agents, 'busy');
const offlineAgents = this.getAgentsByAvailability(agents, 'offline');
const filteredAgents = [...onlineAgents, ...busyAgents, ...offlineAgents];
return filteredAgents;
},
getAgentsByUpdatedPresence(agents) {
// Here we are updating the availability status of the current user dynamically (live) based on the current account availability status
const agentsWithDynamicPresenceUpdate = agents.map(item =>
item.id === this.currentUser.id
? {
...item,
availability_status: this.currentUser.accounts.find(
account => account.id === this.currentAccountId
).availability_status,
}
: item
);
return agentsWithDynamicPresenceUpdate;
},
},
};
-108
View File
@@ -1,108 +0,0 @@
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { OPEN_AI_EVENTS } from '../helper/AnalyticsHelper/events';
import OpenAPI from '../api/integrations/openapi';
export default {
mounted() {
this.fetchIntegrationsIfRequired();
},
computed: {
...mapGetters({
uiFlags: 'integrations/getUIFlags',
appIntegrations: 'integrations/getAppIntegrations',
currentChat: 'getSelectedChat',
replyMode: 'draftMessages/getReplyEditorMode',
}),
aiIntegration() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
)?.hooks[0];
},
isAIIntegrationEnabled() {
return !!this.aiIntegration;
},
isLabelSuggestionFeatureEnabled() {
if (this.aiIntegration) {
const { settings = {} } = this.aiIntegration || {};
return settings.label_suggestion;
}
return false;
},
isFetchingAppIntegrations() {
return this.uiFlags.isFetching;
},
hookId() {
return this.aiIntegration.id;
},
draftMessage() {
return this.$store.getters['draftMessages/get'](this.draftKey);
},
draftKey() {
return `draft-${this.conversationId}-${this.replyMode}`;
},
conversationId() {
return this.currentChat?.id;
},
},
methods: {
async fetchIntegrationsIfRequired() {
if (!this.appIntegrations.length) {
await this.$store.dispatch('integrations/get');
}
},
async recordAnalytics(type, payload) {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
this.$track(event, {
type,
...payload,
});
}
},
async fetchLabelSuggestions({ conversationId }) {
if (!conversationId) return [];
try {
const result = await OpenAPI.processEvent({
type: 'label_suggestion',
hookId: this.hookId,
conversationId: conversationId,
});
const {
data: { message: labels },
} = result;
return this.cleanLabels(labels);
} catch (error) {
return [];
}
},
cleanLabels(labels) {
return labels
.toLowerCase() // Set it to lowercase
.split(',') // split the string into an array
.filter(label => label.trim()) // remove any empty strings
.map(label => label.trim()) // trim the words
.filter((label, index, self) => self.indexOf(label) === index); // remove any duplicates
},
async processEvent(type = 'rephrase') {
try {
const result = await OpenAPI.processEvent({
hookId: this.hookId,
type,
content: this.draftMessage,
conversationId: this.conversationId,
});
const {
data: { message: generatedMessage },
} = result;
return generatedMessage;
} catch (error) {
useAlert(this.$t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR'));
return '';
}
},
},
};
@@ -1,246 +0,0 @@
export default {
allAgents: [
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
],
formattedAgents: [
{
account_id: 0,
confirmed: true,
email: 'None',
id: 0,
name: 'None',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
],
onlineAgents: [
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
],
busyAgents: [
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
],
offlineAgents: [
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
],
sortedByAvailability: [
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
],
formattedAgentsByPresenceOnline: [
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abr@chatwoot.com',
id: 1,
name: 'Abraham Keta',
role: 'agent',
},
],
formattedAgentsByPresenceOffline: [
{
account_id: 1,
availability_status: 'offline',
available_name: 'Abraham',
confirmed: true,
email: 'abr@chatwoot.com',
id: 1,
name: 'Abraham Keta',
role: 'agent',
},
],
};
@@ -1,140 +0,0 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import agentMixin from '../agentMixin';
import agentFixtures from './agentFixtures';
import Vuex from 'vuex';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('agentMixin', () => {
let getters;
let store;
beforeEach(() => {
getters = {
getCurrentUser: () => ({
id: 1,
accounts: [
{
id: 1,
availability_status: 'online',
auto_offline: false,
},
],
}),
getCurrentAccountId: () => 1,
};
store = new Vuex.Store({ getters });
});
it('return agents by availability', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [agentMixin],
data() {
return {
inboxId: 1,
currentChat: { meta: { assignee: { name: 'John' } } },
};
},
computed: {
assignableAgents() {
return agentFixtures.allAgents;
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(
wrapper.vm.getAgentsByAvailability(agentFixtures.allAgents, 'online')
).toEqual(agentFixtures.onlineAgents);
expect(
wrapper.vm.getAgentsByAvailability(agentFixtures.allAgents, 'busy')
).toEqual(agentFixtures.busyAgents);
expect(
wrapper.vm.getAgentsByAvailability(agentFixtures.allAgents, 'offline')
).toEqual(agentFixtures.offlineAgents);
});
it('return sorted agents by availability', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [agentMixin],
data() {
return {
inboxId: 1,
currentChat: { meta: { assignee: { name: 'John' } } },
};
},
computed: {
assignableAgents() {
return agentFixtures.allAgents;
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(
wrapper.vm.sortedAgentsByAvailability(agentFixtures.allAgents)
).toEqual(agentFixtures.sortedByAvailability);
});
it('return formatted agents', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [agentMixin],
data() {
return {
inboxId: 1,
currentChat: { meta: { assignee: { name: 'John' } } },
};
},
computed: {
assignableAgents() {
return agentFixtures.allAgents;
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.agentsList).toEqual(agentFixtures.formattedAgents);
});
it('return formatted agents by presence', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [agentMixin],
data() {
return {
inboxId: 1,
currentChat: { meta: { assignee: { name: 'John' } } },
};
},
computed: {
currentUser() {
return {
id: 1,
accounts: [
{
id: 1,
availability_status: 'offline',
auto_offline: false,
},
],
};
},
currentAccountId() {
return 1;
},
assignableAgents() {
return agentFixtures.allAgents;
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(
wrapper.vm.getAgentsByUpdatedPresence(
agentFixtures.formattedAgentsByPresenceOnline
)
).toEqual(agentFixtures.formattedAgentsByPresenceOffline);
});
});
@@ -1,95 +0,0 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import aiMixin from '../aiMixin';
import Vuex from 'vuex';
import OpenAPI from '../../api/integrations/openapi';
import { LocalStorage } from '../../../shared/helpers/localStorage';
vi.mock('../../api/integrations/openapi');
vi.mock('../../../shared/helpers/localStorage');
const localVue = createLocalVue();
localVue.use(Vuex);
describe('aiMixin', () => {
let wrapper;
let getters;
let emptyGetters;
let component;
let actions;
beforeEach(() => {
OpenAPI.processEvent = vi.fn();
LocalStorage.set = vi.fn();
LocalStorage.get = vi.fn();
actions = {
['integrations/get']: vi.fn(),
};
getters = {
['integrations/getAppIntegrations']: () => [
{
id: 'openai',
hooks: [{ id: 'hook1' }],
},
],
};
component = {
render() {},
title: 'TestComponent',
mixins: [aiMixin],
};
wrapper = shallowMount(component, {
store: new Vuex.Store({
getters: getters,
actions,
}),
localVue,
});
emptyGetters = {
['integrations/getAppIntegrations']: () => [],
};
});
it('fetches integrations if required', async () => {
wrapper = shallowMount(component, {
store: new Vuex.Store({
getters: emptyGetters,
actions,
}),
localVue,
});
const dispatchSpy = vi.spyOn(wrapper.vm.$store, 'dispatch');
await wrapper.vm.fetchIntegrationsIfRequired();
expect(dispatchSpy).toHaveBeenCalledWith('integrations/get');
});
it('does not fetch integrations', async () => {
const dispatchSpy = vi.spyOn(wrapper.vm.$store, 'dispatch');
await wrapper.vm.fetchIntegrationsIfRequired();
expect(dispatchSpy).not.toHaveBeenCalledWith('integrations/get');
expect(wrapper.vm.isAIIntegrationEnabled).toBeTruthy();
});
it('fetches label suggestions', async () => {
const processEventSpy = vi.spyOn(OpenAPI, 'processEvent');
await wrapper.vm.fetchLabelSuggestions({
conversationId: '123',
});
expect(processEventSpy).toHaveBeenCalledWith({
type: 'label_suggestion',
hookId: 'hook1',
conversationId: '123',
});
});
it('cleans labels', () => {
const labels = 'label1, label2, label1';
expect(wrapper.vm.cleanLabels(labels)).toEqual(['label1', 'label2']);
});
});
@@ -5,7 +5,6 @@ import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vu
const emit = defineEmits(['add']);
const addNoteRef = ref(null);
const noteContent = ref('');
const buttonDisabled = computed(() => noteContent.value === '');
@@ -23,12 +22,11 @@ const keyboardEvents = {
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, addNoteRef);
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
ref="addNoteRef"
class="flex flex-col flex-grow p-4 mb-2 overflow-hidden bg-white border border-solid rounded-md shadow-sm border-slate-75 dark:border-slate-700 dark:bg-slate-900 text-slate-700 dark:text-slate-100"
>
<WootMessageEditor
@@ -1,18 +1,18 @@
<script>
import '@chatwoot/ninja-keys';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useAI } from 'dashboard/composables/useAI';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import wootConstants from 'dashboard/constants/globals';
import conversationHotKeysMixin from './conversationHotKeys';
import bulkActionsHotKeysMixin from './bulkActionsHotKeys';
import inboxHotKeysMixin from './inboxHotKeys';
import goToCommandHotKeys from './goToCommandHotKeys';
import appearanceHotKeys from './appearanceHotKeys';
import agentMixin from 'dashboard/mixins/agentMixin';
import { GENERAL_EVENTS } from '../../../helper/AnalyticsHelper/events';
export default {
mixins: [
agentMixin,
conversationHotKeysMixin,
bulkActionsHotKeysMixin,
inboxHotKeysMixin,
@@ -28,11 +28,17 @@ export default {
removeLabelFromConversation,
} = useConversationLabels();
const { isAIIntegrationEnabled } = useAI();
const { agentsList, assignableAgents } = useAgentsList();
return {
agentsList,
assignableAgents,
activeLabels,
inactiveLabels,
addLabelToConversation,
removeLabelFromConversation,
isAIIntegrationEnabled,
};
},
data() {
@@ -4,7 +4,6 @@ import { emitter } from 'shared/helpers/mitt';
import { CMD_AI_ASSIST } from './commandBarBusEvents';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import aiMixin from 'dashboard/mixins/aiMixin';
import {
ICON_ADD_LABEL,
ICON_ASSIGN_AGENT,
@@ -36,7 +35,6 @@ import {
isAInboxViewRoute,
} from '../../../helper/routeHelpers';
export default {
mixins: [aiMixin],
watch: {
assignableAgents() {
this.setCommandbarData();
@@ -2,10 +2,10 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import ContactDetailsItem from './ContactDetailsItem.vue';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import ConversationLabels from './labels/LabelBox.vue';
import agentMixin from 'dashboard/mixins/agentMixin';
import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
@@ -15,19 +15,17 @@ export default {
MultiselectDropdown,
ConversationLabels,
},
mixins: [agentMixin],
props: {
conversationId: {
type: [Number, String],
required: true,
},
// inboxId prop is used in /mixins/agentMixin,
// remove this props when refactoring to composable if not needed
// eslint-disable-next-line vue/no-unused-properties
inboxId: {
type: Number,
default: undefined,
},
},
setup() {
const { agentsList } = useAgentsList();
return {
agentsList,
};
},
data() {
return {
@@ -2,7 +2,8 @@
import Spinner from 'shared/components/Spinner.vue';
import { useAlert } from 'dashboard/composables';
import { mapGetters } from 'vuex';
import agentMixin from 'dashboard/mixins/agentMixin';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import ThumbnailGroup from 'dashboard/components/widgets/ThumbnailGroup.vue';
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
@@ -12,19 +13,17 @@ export default {
ThumbnailGroup,
MultiselectDropdownItems,
},
mixins: [agentMixin],
props: {
conversationId: {
type: [Number, String],
required: true,
},
// inboxId prop is used in /mixins/agentMixin,
// remove this props when refactoring to composable if not needed
// eslint-disable-next-line vue/no-unused-properties
inboxId: {
type: Number,
default: undefined,
},
},
setup() {
const { agentsList } = useAgentsList(false);
return {
agentsList,
};
},
data() {
return {
@@ -87,6 +87,17 @@ export default {
this.fetchConversationIfUnavailable();
},
},
created() {
// Clear selected state early if no conversation is selected
// This prevents child components from accessing stale data
// and resolves timing issues during navigation
// with conversation view and other screens
if (!this.conversationId) {
this.$store.dispatch('clearSelectedState');
}
},
mounted() {
this.$store.dispatch('agents/get');
this.initialize();
@@ -25,7 +25,6 @@ export default {
removeLabelFromConversation,
} = useConversationLabels();
const conversationLabelBoxRef = ref(null);
const showSearchDropdownLabel = ref(false);
const toggleLabels = () => {
@@ -52,7 +51,7 @@ export default {
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, conversationLabelBoxRef);
useKeyboardEvents(keyboardEvents);
return {
isAdmin,
savedLabels,
@@ -60,7 +59,6 @@ export default {
accountLabels,
addLabelToConversation,
removeLabelFromConversation,
conversationLabelBoxRef,
showSearchDropdownLabel,
closeDropdownLabel,
toggleLabels,
@@ -81,7 +79,7 @@ export default {
</script>
<template>
<div ref="conversationLabelBoxRef" class="sidebar-labels-wrap">
<div class="sidebar-labels-wrap">
<div
v-if="!conversationUiFlags.isFetching"
class="contact-conversation--list"
@@ -11,7 +11,6 @@ defineProps({
const emit = defineEmits(['search', 'close']);
const articleSearchHeaderRef = ref(null);
const searchInputRef = ref(null);
const searchQuery = ref('');
@@ -41,11 +40,11 @@ const keyboardEvents = {
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, articleSearchHeaderRef);
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div ref="articleSearchHeaderRef" class="flex flex-col py-1">
<div class="flex flex-col py-1">
<div class="flex items-center justify-between py-2 mb-1">
<h3 class="text-base text-slate-900 dark:text-slate-25">
{{ title }}
@@ -0,0 +1,79 @@
<script setup>
import { messageStamp } from 'shared/helpers/timeHelper';
const props = defineProps({
automation: {
type: Object,
required: true,
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['toggle', 'edit', 'delete', 'clone']);
const readableDate = date => messageStamp(new Date(date), 'LLL d, yyyy');
const readableDateWithTime = date =>
messageStamp(new Date(date), 'LLL d, yyyy hh:mm a');
const toggle = () => {
const { id, name, active } = props.automation;
emit('toggle', {
id,
name,
status: active,
});
};
</script>
<template>
<tr>
<td class="py-4 ltr:pr-4 rtl:pl-4 min-w-[200px]">{{ automation.name }}</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">{{ automation.description }}</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<woot-switch :value="automation.active" @input="toggle" />
</td>
<td
class="py-4 ltr:pr-4 rtl:pl-4 min-w-[12px]"
:title="readableDateWithTime(automation.created_on)"
>
{{ readableDate(automation.created_on) }}
</td>
<td class="py-4 min-w-xs">
<div class="flex gap-1 justify-end flex-shrink-0">
<woot-button
v-tooltip.top="$t('AUTOMATION.FORM.EDIT')"
variant="smooth"
size="tiny"
color-scheme="secondary"
class-names="grey-btn"
icon="edit"
:is-loading="loading"
@click="$emit('edit', automation)"
/>
<woot-button
v-tooltip.top="$t('AUTOMATION.CLONE.TOOLTIP')"
variant="smooth"
size="tiny"
:is-loading="loading"
color-scheme="primary"
class-names="grey-btn"
icon="copy"
@click="$emit('clone', automation)"
/>
<woot-button
v-tooltip.top="$t('AUTOMATION.FORM.DELETE')"
variant="smooth"
:is-loading="loading"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
class-names="grey-btn"
@click="$emit('delete', automation)"
/>
</div>
</td>
</tr>
</template>
@@ -1,246 +1,219 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { useAlert } from 'dashboard/composables';
import { messageStamp } from 'shared/helpers/timeHelper';
import AddAutomationRule from './AddAutomationRule.vue';
import EditAutomationRule from './EditAutomationRule.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import AutomationRuleRow from './AutomationRuleRow.vue';
const getters = useStoreGetters();
const store = useStore();
const { t } = useI18n();
const confirmDialog = ref(null);
export default {
components: {
AddAutomationRule,
EditAutomationRule,
},
data() {
return {
loading: {},
showAddPopup: false,
showEditPopup: false,
showDeleteConfirmationPopup: false,
selectedResponse: {},
toggleModalTitle: this.$t('AUTOMATION.TOGGLE.ACTIVATION_TITLE'),
toggleModalDescription: this.$t(
'AUTOMATION.TOGGLE.ACTIVATION_DESCRIPTION'
),
};
},
computed: {
...mapGetters({
records: ['automations/getAutomations'],
uiFlags: 'automations/getUIFlags',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
// Delete Modal
deleteConfirmText() {
return `${this.$t('AUTOMATION.DELETE.CONFIRM.YES')} ${
this.selectedResponse.name
}`;
},
deleteRejectText() {
return `${this.$t('AUTOMATION.DELETE.CONFIRM.NO')} ${
this.selectedResponse.name
}`;
},
deleteMessage() {
return ` ${this.selectedResponse.name}?`;
},
isSLAEnabled() {
return this.isFeatureEnabledonAccount(this.accountId, 'sla');
},
},
mounted() {
this.$store.dispatch('inboxes/get');
this.$store.dispatch('agents/get');
this.$store.dispatch('contacts/get');
this.$store.dispatch('teams/get');
this.$store.dispatch('labels/get');
this.$store.dispatch('campaigns/get');
this.$store.dispatch('automations/get');
if (this.isSLAEnabled) this.$store.dispatch('sla/get');
},
methods: {
openAddPopup() {
this.showAddPopup = true;
},
hideAddPopup() {
this.showAddPopup = false;
},
openEditPopup(response) {
this.selectedResponse = response;
this.showEditPopup = true;
},
hideEditPopup() {
this.showEditPopup = false;
},
openDeletePopup(response) {
this.showDeleteConfirmationPopup = true;
this.selectedResponse = response;
},
closeDeletePopup() {
this.showDeleteConfirmationPopup = false;
},
confirmDeletion() {
this.loading[this.selectedResponse.id] = true;
this.closeDeletePopup();
this.deleteAutomation(this.selectedResponse.id);
},
async deleteAutomation(id) {
try {
await this.$store.dispatch('automations/delete', id);
useAlert(this.$t('AUTOMATION.DELETE.API.SUCCESS_MESSAGE'));
this.loading[this.selectedResponse.id] = false;
} catch (error) {
useAlert(this.$t('AUTOMATION.DELETE.API.ERROR_MESSAGE'));
}
},
async cloneAutomation(id) {
try {
await this.$store.dispatch('automations/clone', id);
useAlert(this.$t('AUTOMATION.CLONE.API.SUCCESS_MESSAGE'));
this.$store.dispatch('automations/get');
this.loading[this.selectedResponse.id] = false;
} catch (error) {
useAlert(this.$t('AUTOMATION.CLONE.API.ERROR_MESSAGE'));
}
},
async submitAutomation(payload, mode) {
try {
const action =
mode === 'edit' ? 'automations/update' : 'automations/create';
const successMessage =
mode === 'edit'
? this.$t('AUTOMATION.EDIT.API.SUCCESS_MESSAGE')
: this.$t('AUTOMATION.ADD.API.SUCCESS_MESSAGE');
await this.$store.dispatch(action, payload);
useAlert(successMessage);
this.hideAddPopup();
this.hideEditPopup();
} catch (error) {
const errorMessage =
mode === 'edit'
? this.$t('AUTOMATION.EDIT.API.ERROR_MESSAGE')
: this.$t('AUTOMATION.ADD.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
},
async toggleAutomation(automation, status) {
try {
this.toggleModalTitle = status
? this.$t('AUTOMATION.TOGGLE.DEACTIVATION_TITLE')
: this.$t('AUTOMATION.TOGGLE.ACTIVATION_TITLE');
this.toggleModalDescription = status
? this.$t('AUTOMATION.TOGGLE.DEACTIVATION_DESCRIPTION', {
automationName: automation.name,
})
: this.$t('AUTOMATION.TOGGLE.ACTIVATION_DESCRIPTION', {
automationName: automation.name,
});
// Check if user confirms to proceed
const ok = await this.$refs.confirmDialog.showConfirmation();
if (ok) {
await await this.$store.dispatch('automations/update', {
id: automation.id,
active: !status,
});
const message = status
? this.$t('AUTOMATION.TOGGLE.DEACTIVATION_SUCCESFUL')
: this.$t('AUTOMATION.TOGGLE.ACTIVATION_SUCCESFUL');
useAlert(message);
const loading = ref({});
const showAddPopup = ref(false);
const showEditPopup = ref(false);
const showDeleteConfirmationPopup = ref(false);
const selectedAutomation = ref({});
const toggleModalTitle = ref(t('AUTOMATION.TOGGLE.ACTIVATION_TITLE'));
const toggleModalDescription = ref(
t('AUTOMATION.TOGGLE.ACTIVATION_DESCRIPTION')
);
const records = computed(() => getters['automations/getAutomations'].value);
const uiFlags = computed(() => getters['automations/getUIFlags'].value);
const accountId = computed(() => getters.getCurrentAccountId.value);
const deleteConfirmText = computed(
() => `${t('AUTOMATION.DELETE.CONFIRM.YES')} ${selectedAutomation.value.name}`
);
const deleteRejectText = computed(
() => `${t('AUTOMATION.DELETE.CONFIRM.NO')} ${selectedAutomation.value.name}`
);
const deleteMessage = computed(() => ` ${selectedAutomation.value.name}?`);
const isSLAEnabled = computed(() =>
getters['accounts/isFeatureEnabledonAccount'].value(accountId.value, 'sla')
);
onMounted(() => {
store.dispatch('inboxes/get');
store.dispatch('agents/get');
store.dispatch('contacts/get');
store.dispatch('teams/get');
store.dispatch('labels/get');
store.dispatch('campaigns/get');
store.dispatch('automations/get');
if (isSLAEnabled.value) {
store.dispatch('sla/get');
}
});
const openAddPopup = () => {
showAddPopup.value = true;
};
const hideAddPopup = () => {
showAddPopup.value = false;
};
const openEditPopup = response => {
selectedAutomation.value = response;
showEditPopup.value = true;
};
const hideEditPopup = () => {
showEditPopup.value = false;
};
const openDeletePopup = response => {
showDeleteConfirmationPopup.value = true;
selectedAutomation.value = response;
};
const closeDeletePopup = () => {
showDeleteConfirmationPopup.value = false;
};
const deleteAutomation = async id => {
try {
await store.dispatch('automations/delete', id);
useAlert(t('AUTOMATION.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('AUTOMATION.DELETE.API.ERROR_MESSAGE'));
} finally {
loading.value[selectedAutomation.value.id] = false;
}
};
const confirmDeletion = () => {
loading.value[selectedAutomation.value.id] = true;
closeDeletePopup();
deleteAutomation(selectedAutomation.value.id);
};
const cloneAutomation = async ({ id }) => {
try {
await store.dispatch('automations/clone', id);
useAlert(t('AUTOMATION.CLONE.API.SUCCESS_MESSAGE'));
store.dispatch('automations/get');
} catch (error) {
useAlert(t('AUTOMATION.CLONE.API.ERROR_MESSAGE'));
} finally {
loading.value[selectedAutomation.value.id] = false;
}
};
const submitAutomation = async (payload, mode) => {
try {
const action =
mode === 'edit' ? 'automations/update' : 'automations/create';
const successMessage =
mode === 'edit'
? t('AUTOMATION.EDIT.API.SUCCESS_MESSAGE')
: t('AUTOMATION.ADD.API.SUCCESS_MESSAGE');
await store.dispatch(action, payload);
useAlert(successMessage);
hideAddPopup();
hideEditPopup();
} catch (error) {
const errorMessage =
mode === 'edit'
? t('AUTOMATION.EDIT.API.ERROR_MESSAGE')
: t('AUTOMATION.ADD.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const toggleAutomation = async ({ id, name, status }) => {
try {
if (status) {
toggleModalTitle.value = t('AUTOMATION.TOGGLE.DEACTIVATION_TITLE');
toggleModalDescription.value = t(
'AUTOMATION.TOGGLE.DEACTIVATION_DESCRIPTION',
{
automationName: name,
}
} catch (error) {
useAlert(this.$t('AUTOMATION.EDIT.API.ERROR_MESSAGE'));
}
},
readableTime(date) {
return messageStamp(new Date(date), 'LLL d, h:mm a');
},
},
);
} else {
toggleModalTitle.value = t('AUTOMATION.TOGGLE.ACTIVATION_TITLE');
toggleModalDescription.value = t(
'AUTOMATION.TOGGLE.ACTIVATION_DESCRIPTION',
{
automationName: name,
}
);
}
const ok = await confirmDialog.value.showConfirmation();
if (ok) {
await store.dispatch('automations/update', {
id: id,
active: !status,
});
const message = status
? t('AUTOMATION.TOGGLE.DEACTIVATION_SUCCESFUL')
: t('AUTOMATION.TOGGLE.ACTIVATION_SUCCESFUL');
useAlert(message);
}
} catch (error) {
useAlert(t('AUTOMATION.EDIT.API.ERROR_MESSAGE'));
}
};
</script>
<template>
<div class="flex-1 p-4 overflow-auto">
<woot-button
color-scheme="success"
class-names="button--fixed-top"
icon="add-circle"
@click="openAddPopup()"
>
{{ $t('AUTOMATION.HEADER_BTN_TXT') }}
</woot-button>
<div class="flex flex-row gap-4">
<div class="w-full lg:w-3/5">
<p
v-if="!uiFlags.isFetching && !records.length"
class="flex flex-col items-center justify-center h-full"
<SettingsLayout
:is-loading="uiFlags.isFetching"
:loading-message="$t('AUTOMATION.LOADING')"
:no-records-found="!records.length"
:no-records-message="$t('AUTOMATION.LIST.404')"
>
<template #header>
<BaseSettingsHeader
:title="$t('AUTOMATION.HEADER')"
:description="$t('AUTOMATION.DESCRIPTION')"
:link-text="$t('AUTOMATION.LEARN_MORE')"
feature-name="automation"
>
<template #actions>
<woot-button
class="button nice rounded-md"
icon="add-circle"
@click="openAddPopup"
>
{{ $t('AUTOMATION.HEADER_BTN_TXT') }}
</woot-button>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table class="min-w-full divide-y divide-slate-75 dark:divide-slate-700">
<thead>
<th
v-for="thHeader in $t('AUTOMATION.LIST.TABLE_HEADER')"
:key="thHeader"
class="py-4 pr-4 text-left font-semibold text-slate-700 dark:text-slate-300"
>
{{ thHeader }}
</th>
</thead>
<tbody
class="divide-y divide-slate-50 dark:divide-slate-800 text-slate-700 dark:text-slate-300"
>
{{ $t('AUTOMATION.LIST.404') }}
</p>
<woot-loading-state
v-if="uiFlags.isFetching"
:message="$t('AUTOMATION.LOADING')"
/>
<table v-if="!uiFlags.isFetching && records.length" class="woot-table">
<thead>
<th
v-for="thHeader in $t('AUTOMATION.LIST.TABLE_HEADER')"
:key="thHeader"
>
{{ thHeader }}
</th>
</thead>
<tbody>
<tr v-for="(automation, index) in records" :key="index">
<td>{{ automation.name }}</td>
<td>{{ automation.description }}</td>
<td>
<woot-switch
:value="automation.active"
@input="toggleAutomation(automation, automation.active)"
/>
</td>
<td>{{ readableTime(automation.created_on) }}</td>
<td class="button-wrapper">
<woot-button
v-tooltip.top="$t('AUTOMATION.FORM.EDIT')"
variant="smooth"
size="tiny"
color-scheme="secondary"
class-names="grey-btn"
:is-loading="loading[automation.id]"
icon="edit"
@click="openEditPopup(automation)"
/>
<woot-button
v-tooltip.top="$t('AUTOMATION.CLONE.TOOLTIP')"
variant="smooth"
size="tiny"
color-scheme="primary"
class-names="grey-btn"
:is-loading="loading[automation.id]"
icon="copy"
@click="cloneAutomation(automation.id)"
/>
<woot-button
v-tooltip.top="$t('AUTOMATION.FORM.DELETE')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
class-names="grey-btn"
:is-loading="loading[automation.id]"
@click="openDeletePopup(automation, index)"
/>
</td>
</tr>
</tbody>
</table>
</div>
<AutomationRuleRow
v-for="automation in records"
:key="automation.id"
:automation="automation"
:loading="loading[automation.id]"
@clone="cloneAutomation"
@toggle="toggleAutomation"
@edit="openEditPopup"
@delete="openDeletePopup"
/>
</tbody>
</table>
</template>
<div class="hidden w-1/3 lg:block">
<span v-dompurify-html="$t('AUTOMATION.SIDEBAR_TXT')" />
</div>
</div>
<woot-modal
:show.sync="showAddPopup"
size="medium"
@@ -272,7 +245,7 @@ export default {
<EditAutomationRule
v-if="showEditPopup"
:on-close="hideEditPopup"
:selected-response="selectedResponse"
:selected-response="selectedAutomation"
@saveAutomation="submitAutomation"
/>
</woot-modal>
@@ -281,5 +254,5 @@ export default {
:title="toggleModalTitle"
:description="toggleModalDescription"
/>
</div>
</SettingsLayout>
</template>
@@ -1,17 +1,12 @@
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const SettingsWrapper = () => import('../SettingsWrapper.vue');
const Automation = () => import('./Index.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/automation'),
component: SettingsContent,
props: {
headerTitle: 'AUTOMATION.HEADER',
icon: 'automation',
showNewButton: false,
},
component: SettingsWrapper,
children: [
{
path: '',
@@ -2,7 +2,7 @@
import { isEmptyObject } from '../../../../helper/commons';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import hookMixin from './hookMixin';
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
import NewHook from './NewHook.vue';
import SingleIntegrationHooks from './SingleIntegrationHooks.vue';
import MultipleIntegrationHooks from './MultipleIntegrationHooks.vue';
@@ -13,13 +13,28 @@ export default {
SingleIntegrationHooks,
MultipleIntegrationHooks,
},
mixins: [hookMixin],
props: {
integrationId: {
type: [String, Number],
required: true,
},
},
setup(props) {
const { integrationId } = props;
const {
integration,
isIntegrationMultiple,
isIntegrationSingle,
isHookTypeInbox,
} = useIntegrationHook(integrationId);
return {
integration,
isIntegrationMultiple,
isIntegrationSingle,
isHookTypeInbox,
};
},
data() {
return {
loading: {},
@@ -31,23 +46,9 @@ export default {
},
computed: {
...mapGetters({ uiFlags: 'integrations/getUIFlags' }),
integration() {
return this.$store.getters['integrations/getIntegration'](
this.integrationId
);
},
showIntegrationHooks() {
return !this.uiFlags.isFetching && !isEmptyObject(this.integration);
},
integrationType() {
return this.integration.allow_multiple_hooks ? 'multiple' : 'single';
},
isIntegrationMultiple() {
return this.integrationType === 'multiple';
},
isIntegrationSingle() {
return this.integrationType === 'single';
},
showAddButton() {
return this.showIntegrationHooks && this.isIntegrationMultiple;
},
@@ -120,14 +121,14 @@ export default {
<div v-if="showIntegrationHooks" class="w-full">
<div v-if="isIntegrationMultiple">
<MultipleIntegrationHooks
:integration="integration"
:integration-id="integrationId"
@delete="openDeletePopup"
/>
</div>
<div v-if="isIntegrationSingle">
<SingleIntegrationHooks
:integration="integration"
:integration-id="integrationId"
@add="openAddHookModal"
@delete="openDeletePopup"
/>
@@ -135,7 +136,7 @@ export default {
</div>
<woot-modal :show.sync="showAddHookModal" :on-close="hideAddHookModal">
<NewHook :integration="integration" @close="hideAddHookModal" />
<NewHook :integration-id="integrationId" @close="hideAddHookModal" />
</woot-modal>
<woot-delete-modal
@@ -1,14 +1,18 @@
<script>
import { mapGetters } from 'vuex';
import hookMixin from './hookMixin';
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
export default {
mixins: [hookMixin],
props: {
integration: {
type: Object,
default: () => ({}),
integrationId: {
type: String,
required: true,
},
},
setup(props) {
const { integration, isHookTypeInbox, hasConnectedHooks } =
useIntegrationHook(props.integrationId);
return { integration, isHookTypeInbox, hasConnectedHooks };
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
@@ -2,16 +2,22 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import hookMixin from './hookMixin';
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
export default {
mixins: [hookMixin],
props: {
integration: {
type: Object,
default: () => ({}),
integrationId: {
type: String,
required: true,
},
},
setup(props) {
const { integration, isHookTypeInbox } = useIntegrationHook(
props.integrationId
);
return { integration, isHookTypeInbox };
},
data() {
return {
endPoint: '',
@@ -1,13 +1,18 @@
<script>
import hookMixin from './hookMixin';
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
export default {
mixins: [hookMixin],
props: {
integration: {
type: Object,
default: () => ({}),
integrationId: {
type: String,
required: true,
},
},
setup(props) {
const { integration, hasConnectedHooks } = useIntegrationHook(
props.integrationId
);
return { integration, hasConnectedHooks };
},
};
</script>
@@ -1,117 +1,112 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import MacrosTableRow from './MacrosTableRow.vue';
export default {
components: {
MacrosTableRow,
},
setup() {
const { accountScopedUrl } = useAccount();
return {
accountScopedUrl,
};
},
data() {
return {
showDeleteConfirmationPopup: false,
selectedResponse: {},
loading: {},
};
},
computed: {
...mapGetters({
records: ['macros/getMacros'],
uiFlags: 'macros/getUIFlags',
}),
deleteMessage() {
return ` ${this.selectedResponse.name}?`;
},
},
mounted() {
this.$store.dispatch('macros/get');
},
methods: {
openDeletePopup(response) {
this.showDeleteConfirmationPopup = true;
this.selectedResponse = response;
},
closeDeletePopup() {
this.showDeleteConfirmationPopup = false;
},
confirmDeletion() {
this.loading[this.selectedResponse.id] = true;
this.closeDeletePopup();
this.deleteMacro(this.selectedResponse.id);
},
async deleteMacro(id) {
try {
await this.$store.dispatch('macros/delete', id);
useAlert(this.$t('MACROS.DELETE.API.SUCCESS_MESSAGE'));
this.loading[this.selectedResponse.id] = false;
} catch (error) {
useAlert(this.$t('MACROS.DELETE.API.ERROR_MESSAGE'));
}
},
},
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
const getters = useStoreGetters();
const store = useStore();
const { t } = useI18n();
const showDeleteConfirmationPopup = ref(false);
const selectedMacro = ref({});
const records = computed(() => getters['macros/getMacros'].value);
const uiFlags = computed(() => getters['macros/getUIFlags'].value);
const deleteMessage = computed(() => ` ${selectedMacro.value.name}?`);
onMounted(() => {
store.dispatch('macros/get');
});
const deleteMacro = async id => {
try {
await store.dispatch('macros/delete', id);
useAlert(t('MACROS.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('MACROS.DELETE.API.ERROR_MESSAGE'));
}
};
const openDeletePopup = response => {
showDeleteConfirmationPopup.value = true;
selectedMacro.value = response;
};
const closeDeletePopup = () => {
showDeleteConfirmationPopup.value = false;
};
const confirmDeletion = () => {
closeDeletePopup();
deleteMacro(selectedMacro.value.id);
};
</script>
<template>
<div class="flex-1 overflow-auto">
<router-link
:to="accountScopedUrl('settings/macros/new')"
class="button success button--fixed-top button success button--fixed-top px-3.5 py-1 rounded-[5px] flex gap-2"
>
<fluent-icon icon="add-circle" />
<span class="button__content">
{{ $t('MACROS.HEADER_BTN_TXT') }}
</span>
</router-link>
<div class="flex flex-row gap-4 p-8">
<div class="w-full lg:w-3/5">
<div v-if="!uiFlags.isFetching && !records.length" class="p-3">
<p class="flex flex-col items-center justify-center h-full">
{{ $t('MACROS.LIST.404') }}
</p>
</div>
<woot-loading-state
v-if="uiFlags.isFetching"
:message="$t('MACROS.LOADING')"
/>
<table v-if="!uiFlags.isFetching && records.length" class="woot-table">
<thead>
<th
v-for="thHeader in $t('MACROS.LIST.TABLE_HEADER')"
:key="thHeader"
>
{{ thHeader }}
</th>
</thead>
<tbody>
<MacrosTableRow
v-for="(macro, index) in records"
:key="index"
:macro="macro"
@delete="openDeletePopup(macro, index)"
/>
</tbody>
</table>
</div>
<div class="hidden w-1/3 lg:block">
<span v-dompurify-html="$t('MACROS.SIDEBAR_TXT')" />
</div>
</div>
<woot-delete-modal
:show.sync="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
:title="$t('LABEL_MGMT.DELETE.CONFIRM.TITLE')"
:message="$t('MACROS.DELETE.CONFIRM.MESSAGE')"
:message-value="deleteMessage"
:confirm-text="$t('MACROS.DELETE.CONFIRM.YES')"
:reject-text="$t('MACROS.DELETE.CONFIRM.NO')"
/>
</div>
<SettingsLayout
:no-records-message="$t('MACROS.LIST.404')"
:no-records-found="!records.length"
:is-loading="uiFlags.isFetching"
:loading-message="$t('MACROS.LOADING')"
feature-name="macros"
>
<template #header>
<BaseSettingsHeader
:title="$t('MACROS.HEADER')"
:description="$t('MACROS.DESCRIPTION')"
:link-text="$t('MACROS.LEARN_MORE')"
feature-name="macros"
>
<template #actions>
<router-link
:to="{ name: 'macros_new' }"
class="button rounded-md primary"
>
<fluent-icon icon="add-circle" />
<span class="button__content">
{{ $t('MACROS.HEADER_BTN_TXT') }}
</span>
</router-link>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table class="min-w-full divide-y divide-slate-75 dark:divide-slate-700">
<thead>
<th
v-for="thHeader in $t('MACROS.LIST.TABLE_HEADER')"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-slate-700 dark:text-slate-300"
>
{{ thHeader }}
</th>
</thead>
<tbody
class="divide-y divide-slate-50 dark:divide-slate-800 text-slate-700 dark:text-slate-300"
>
<MacrosTableRow
v-for="(macro, index) in records"
:key="index"
:macro="macro"
@delete="openDeletePopup(macro)"
/>
</tbody>
</table>
<woot-delete-modal
:show.sync="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
:title="$t('LABEL_MGMT.DELETE.CONFIRM.TITLE')"
:message="$t('MACROS.DELETE.CONFIRM.MESSAGE')"
:message-value="deleteMessage"
:confirm-text="$t('MACROS.DELETE.CONFIRM.YES')"
:reject-text="$t('MACROS.DELETE.CONFIRM.NO')"
/>
</template>
</SettingsLayout>
</template>
@@ -1,59 +1,56 @@
<script>
import { useAccount } from 'dashboard/composables/useAccount';
<script setup>
import { computed } from 'vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
components: {
Thumbnail,
},
props: {
macro: {
type: Object,
required: true,
},
},
setup() {
const { accountScopedUrl } = useAccount();
import { useI18n } from 'dashboard/composables/useI18n';
return {
accountScopedUrl,
};
const props = defineProps({
macro: {
type: Object,
required: true,
},
computed: {
createdByName() {
const createdBy = this.macro.created_by;
return createdBy.available_name ?? createdBy.email ?? '';
},
updatedByName() {
const updatedBy = this.macro.updated_by;
return updatedBy.available_name ?? updatedBy.email ?? '';
},
visibilityLabel() {
return this.macro.visibility === 'global'
? this.$t('MACROS.EDITOR.VISIBILITY.GLOBAL.LABEL')
: this.$t('MACROS.EDITOR.VISIBILITY.PERSONAL.LABEL');
},
},
};
});
defineEmits(['delete']);
const { t } = useI18n();
const createdByName = computed(() => {
const createdBy = props.macro.created_by;
return createdBy.available_name ?? createdBy.email ?? '';
});
const updatedByName = computed(() => {
const updatedBy = props.macro.updated_by;
return updatedBy.available_name ?? updatedBy.email ?? '';
});
const visibilityLabel = computed(() => {
const i18nKey =
props.macro.visibility === 'global'
? 'MACROS.EDITOR.VISIBILITY.GLOBAL.LABEL'
: 'MACROS.EDITOR.VISIBILITY.PERSONAL.LABEL';
return t(i18nKey);
});
</script>
<template>
<tr>
<td>{{ macro.name }}</td>
<td>
<div v-if="macro.created_by" class="avatar-container">
<td class="py-4 ltr:pr-4 rtl:pl-4 truncate">{{ macro.name }}</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div v-if="macro.created_by" class="flex items-center">
<Thumbnail :username="createdByName" size="24px" />
<span>{{ createdByName }}</span>
<span class="mx-2">{{ createdByName }}</span>
</div>
<div v-else>--</div>
</td>
<td>
<div v-if="macro.updated_by" class="avatar-container">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div v-if="macro.updated_by" class="flex items-center">
<Thumbnail :username="updatedByName" size="24px" />
<span>{{ updatedByName }}</span>
<span class="mx-2">{{ updatedByName }}</span>
</div>
<div v-else>--</div>
</td>
<td>{{ visibilityLabel }}</td>
<td class="button-wrapper">
<router-link :to="accountScopedUrl(`settings/macros/${macro.id}/edit`)">
<td class="py-4 ltr:pr-4 rtl:pl-4">{{ visibilityLabel }}</td>
<td class="py-4 flex justify-end gap-1">
<router-link :to="{ name: 'macros_edit', params: { macroId: macro.id } }">
<woot-button
v-tooltip.top="$t('MACROS.EDIT.TOOLTIP')"
variant="smooth"
@@ -75,15 +72,3 @@ export default {
</td>
</tr>
</template>
<style scoped lang="scss">
.avatar-container {
display: flex;
align-items: center;
span {
margin-left: var(--space-small);
margin-right: var(--space-small);
}
}
</style>
@@ -1,6 +1,7 @@
import { frontendURL } from 'dashboard/helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const SettingsWrapper = () => import('../SettingsWrapper.vue');
const Macros = () => import('./Index.vue');
const MacroEditor = () => import('./MacroEditor.vue');
@@ -8,16 +9,7 @@ export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/macros'),
component: SettingsContent,
props: params => {
const showBackButton = params.name !== 'macros_wrapper';
return {
headerTitle: 'MACROS.HEADER',
headerButtonText: 'MACROS.HEADER_BTN_TXT',
icon: 'flash-settings',
showBackButton,
};
},
component: SettingsWrapper,
children: [
{
path: '',
@@ -27,6 +19,27 @@ export default {
permissions: ['administrator', 'agent'],
},
},
],
},
{
path: frontendURL('accounts/:accountId/settings/macros'),
component: SettingsContent,
props: () => {
return {
headerTitle: 'MACROS.HEADER',
icon: 'flash-settings',
showBackButton: true,
};
},
children: [
{
path: ':macroId/edit',
name: 'macros_edit',
component: MacroEditor,
meta: {
permissions: ['administrator', 'agent'],
},
},
{
path: 'new',
name: 'macros_new',
@@ -35,14 +48,6 @@ export default {
permissions: ['administrator', 'agent'],
},
},
{
path: ':macroId/edit',
name: 'macros_edit',
component: MacroEditor,
meta: {
permissions: ['administrator', 'agent'],
},
},
],
},
],
+16 -4
View File
@@ -34,8 +34,14 @@ describe('#validateAuthenticateRoutePermission', () => {
getCurrentUser: {
account_id: 1,
id: 1,
permissions: ['agent'],
accounts: [{ id: 1, role: 'agent', status: 'active' }],
accounts: [
{
permissions: ['agent'],
id: 1,
role: 'agent',
status: 'active',
},
],
},
};
validateAuthenticateRoutePermission(to, next, { getters });
@@ -55,8 +61,14 @@ describe('#validateAuthenticateRoutePermission', () => {
getCurrentUser: {
account_id: 1,
id: 1,
permissions: ['administrator'],
accounts: [{ id: 1, role: 'administrator', status: 'active' }],
accounts: [
{
id: 1,
role: 'administrator',
permissions: ['administrator'],
status: 'active',
},
],
},
};
validateAuthenticateRoutePermission(to, next, { getters });
@@ -15,7 +15,7 @@ export const state = {
export const getters = {
getAutomations(_state) {
return _state.records;
return _state.records.sort((a1, a2) => a1.id - a2.id);
},
getUIFlags(_state) {
return _state.uiFlags;
@@ -44,7 +44,6 @@ export default {
};
useKeyboardNavigableList({
elementRef: portalSearchSuggestionsRef,
items: computed(() => props.items),
adjustScroll,
selectedIndex,
@@ -52,7 +52,7 @@ const keyboardEvents = {
},
};
useKeyboardEvents(keyboardEvents, dropdownMenuRef);
useKeyboardEvents(keyboardEvents);
</script>
<template>
+7
View File
@@ -33,12 +33,19 @@ class NotificationListener < BaseListener
return if event.data[:notifiable_assignee_change].blank?
return if conversation.pending?
Rails.logger.info "[NotificationListener] Assignee changed for conversation: #{conversation.id} to #{assignee&.id} [#{assignee&.email}]"
NotificationBuilder.new(
notification_type: 'conversation_assignment',
user: assignee,
account: account,
primary_actor: conversation
).perform
rescue NoMethodError => e
Rails.logger.error "[NotificationListener] Error in assignee_changed: #{e.message}"
exception_tracker = ChatwootExceptionTracker.new(e, account: account,
additional_context: { 'conversation': conversation, 'assignee': assignee })
exception_tracker.capture_exception
end
def message_created(event)
+1 -1
View File
@@ -56,7 +56,7 @@ class Integrations::Hook < ApplicationRecord
when 'openai'
Integrations::Openai::ProcessorService.new(hook: self, event: event).perform if app_id == 'openai'
else
'No processor found'
{ error: 'No processor found' }
end
end
+1
View File
@@ -83,6 +83,7 @@ class Instagram::MessageText < Instagram::WebhooksBaseService
)
return if message_to_delete.blank?
message_to_delete.attachments.destroy_all
message_to_delete.update!(content: I18n.t('conversations.messages.deleted'), deleted: true)
end
@@ -14,7 +14,6 @@ json.provider resource.provider
json.pubsub_token resource.pubsub_token
json.custom_attributes resource.custom_attributes if resource.custom_attributes.present?
json.role resource.active_account_user&.role
json.permissions resource.active_account_user&.permissions
json.ui_settings resource.ui_settings
json.uid resource.uid
json.type resource.type
@@ -27,11 +27,13 @@ module Enterprise::Integrations::OpenaiProcessorService
response = make_api_call(label_suggestion_body)
return response if response[:error].present?
# LLMs are not deterministic, so this is bandaid solution
# To what you ask? Sometimes, the response includes
# "Labels:" in it's response in some format. This is a hacky way to remove it
# TODO: Fix with with a better prompt
response.present? ? response.gsub(/^(label|labels):/i, '') : ''
{ message: response[:message] ? response[:message].gsub(/^(label|labels):/i, '') : '' }
end
private
+24 -7
View File
@@ -5,10 +5,11 @@
############
class ChatwootExceptionTracker
def initialize(exception, user: nil, account: nil)
def initialize(exception, user: nil, account: nil, additional_context: {})
@exception = exception
@user = user
@account = account
@additional_context = additional_context
end
def capture_exception
@@ -20,13 +21,29 @@ class ChatwootExceptionTracker
def capture_exception_with_sentry
Sentry.with_scope do |scope|
if @account.present?
scope.set_context('account', { id: @account.id, name: @account.name })
scope.set_tags(account_id: @account.id)
end
scope.set_user(id: @user.id, email: @user.email) if @user.is_a?(User)
append_account_context(scope)
append_additional_context(scope)
append_user_context(scope)
Sentry.capture_exception(@exception)
end
end
def append_account_context(scope)
return if @account.blank?
scope.set_context('account', { id: @account.id, name: @account.name })
scope.set_tags(account_id: @account.id)
end
def append_additional_context(scope)
@additional_context.each do |key, value|
scope.set_context(key.to_s, value)
end
end
def append_user_context(scope)
return unless @user.is_a?(User)
scope.set_user(id: @user.id, email: @user.email)
end
end
+5 -1
View File
@@ -77,8 +77,12 @@ class Integrations::OpenaiBaseService
response = HTTParty.post(API_URL, headers: headers, body: body)
Rails.logger.info("OpenAI API response: #{response.body}")
return { error: response.parsed_response, error_code: response.code } unless response.success?
choices = JSON.parse(response.body)['choices']
choices.present? ? choices.first['message']['content'] : nil
return { message: choices.first['message']['content'] } if choices.present?
{ message: nil }
end
end
+1 -1
View File
@@ -33,7 +33,7 @@ module Redis::RedisKeys
LATEST_CHATWOOT_VERSION = 'LATEST_CHATWOOT_VERSION'.freeze
# Check if a message create with same source-id is in progress?
MESSAGE_SOURCE_KEY = 'MESSAGE_SOURCE_KEY::%<id>s'.freeze
OPENAI_CONVERSATION_KEY = 'OPEN_AI_CONVERSATION_KEY::%<event_name>s::%<conversation_id>d::%<updated_at>d'.freeze
OPENAI_CONVERSATION_KEY = 'OPEN_AI_CONVERSATION_KEY::v1::%<event_name>s::%<conversation_id>d::%<updated_at>d'.freeze
## Sempahores / Locks
# We don't want to process messages from the same sender concurrently to prevent creating double conversations
@@ -97,9 +97,8 @@ RSpec.describe 'Integration Hooks API', type: :request do
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['message']).to eq('No processor found')
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq 'No processor found'
end
end
end
@@ -50,7 +50,7 @@ RSpec.describe 'Session', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['data']['permissions']).to eq(['agent'])
expect(response.parsed_body['data']['accounts'].first['permissions']).to eq(['agent'])
end
end
@@ -54,7 +54,7 @@ RSpec.describe Integrations::Openai::ProcessorService do
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq('This is a reply from openai.')
expect(result).to eq({ :message => 'This is a reply from openai.' })
end
it 'returns empty string if openai response is blank' do
@@ -63,7 +63,7 @@ RSpec.describe Integrations::Openai::ProcessorService do
.to_return(status: 200, body: '{}', headers: {})
result = subject.perform
expect(result).to eq('')
expect(result).to eq({ :message => '' })
end
end
@@ -81,9 +81,7 @@ describe Webhooks::InstagramEventsJob do
end
it 'handle instagram unsend message event' do
create(:message,
source_id: 'message-id-to-delete',
inbox_id: instagram_inbox.id)
message = create(:message, inbox_id: instagram_inbox.id, source_id: 'message-id-to-delete')
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
@@ -93,10 +91,15 @@ describe Webhooks::InstagramEventsJob do
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
message.attachments.new(file_type: :image, external_url: 'https://www.example.com/test.jpeg')
expect(instagram_inbox.messages.count).to be 1
instagram_webhook.perform_now(unsend_event[:entry])
expect(instagram_inbox.messages.last.content).to eq 'This message was deleted'
expect(instagram_inbox.messages.last.deleted).to be true
expect(instagram_inbox.messages.last.attachments.count).to be 0
expect(instagram_inbox.messages.last.reload.deleted).to be true
end
@@ -22,5 +22,20 @@ describe ChatwootExceptionTracker do
described_class.new('random').capture_exception
end
end
it 'sets additional context when provided' do
additional_context = { key1: 'value1', key2: 'value2' }
with_modified_env SENTRY_DSN: 'random dsn' do
scope = instance_double(Sentry::Scope)
allow(Sentry).to receive(:with_scope).and_yield(scope)
expect(scope).to receive(:set_context).with('key1', 'value1')
expect(scope).to receive(:set_context).with('key2', 'value2')
expect(Sentry).to receive(:capture_exception).with('random')
described_class.new('random', additional_context: additional_context).capture_exception
end
end
end
end
@@ -52,7 +52,7 @@ RSpec.describe Integrations::Openai::ProcessorService do
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq('This is a reply from openai.')
expect(result).to eq({ :message => 'This is a reply from openai.' })
end
end
@@ -76,7 +76,7 @@ RSpec.describe Integrations::Openai::ProcessorService do
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq('This is a reply from openai.')
expect(result).to eq({ :message => 'This is a reply from openai.' })
end
end
@@ -101,7 +101,7 @@ RSpec.describe Integrations::Openai::ProcessorService do
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq('This is a reply from openai.')
expect(result).to eq({ :message => 'This is a reply from openai.' })
end
end
@@ -140,7 +140,7 @@ RSpec.describe Integrations::Openai::ProcessorService do
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq('This is a reply from openai.')
expect(result).to eq({ :message => 'This is a reply from openai.' })
end
end
@@ -162,7 +162,7 @@ RSpec.describe Integrations::Openai::ProcessorService do
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq('This is a reply from openai.')
expect(result).to eq({ :message => 'This is a reply from openai.' })
end
end
@@ -184,7 +184,7 @@ RSpec.describe Integrations::Openai::ProcessorService do
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq('This is a reply from openai.')
expect(result).to eq({ :message => 'This is a reply from openai.' })
end
end
@@ -206,7 +206,7 @@ RSpec.describe Integrations::Openai::ProcessorService do
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq('This is a reply from openai.')
expect(result).to eq({ :message => 'This is a reply from openai.' })
end
end
@@ -228,7 +228,7 @@ RSpec.describe Integrations::Openai::ProcessorService do
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq('This is a reply from openai.')
expect(result).to eq({ :message => 'This is a reply from openai.' })
end
end
@@ -250,7 +250,7 @@ RSpec.describe Integrations::Openai::ProcessorService do
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq('This is a reply from openai.')
expect(result).to eq({ :message => 'This is a reply from openai.' })
end
end
end
+1 -1
View File
@@ -37,7 +37,7 @@ RSpec.describe Integrations::Hook do
it 'returns no processor found for hooks with out processor defined' do
hook = create(:integrations_hook, account: account)
expect(hook.process_event(params)).to eq('No processor found')
expect(hook.process_event(params)).to eq({ :error => 'No processor found' })
end
it 'returns results from procesor for openai hook' do