Merge branch 'develop' of github.com:chatwoot/chatwoot into feat/captain-inbox-selector

This commit is contained in:
Shivam Mishra
2024-08-14 13:15:31 +05:30
117 changed files with 2746 additions and 2355 deletions
+3 -7
View File
@@ -10,7 +10,6 @@ import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable';
import WootSnackbarBox from './components/SnackbarContainer.vue';
import rtlMixin from 'shared/mixins/rtlMixin';
import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import {
@@ -32,9 +31,6 @@ export default {
UpgradeBanner,
PendingEmailVerificationBanner,
},
mixins: [rtlMixin],
data() {
return {
showAddAccountModal: false,
@@ -46,6 +42,7 @@ export default {
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
isRTL: 'accounts/isRTL',
currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
@@ -102,7 +99,6 @@ export default {
this.getAccount(this.currentAccountId);
const { pubsub_token: pubsubToken } = this.currentUser || {};
this.setLocale(locale);
this.updateRTLDirectionView(locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(pubsubToken);
this.reconnectService = new ReconnectService(this.$store, router);
@@ -124,8 +120,8 @@ export default {
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
:class="{ 'app-rtl--wrapper': isRTLView }"
:dir="isRTLView ? 'rtl' : 'ltr'"
:class="{ 'app-rtl--wrapper': isRTL }"
:dir="isRTL ? 'rtl' : 'ltr'"
>
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
<template v-if="currentAccountId">
@@ -10,7 +10,6 @@ import ChatListHeader from './ChatListHeader.vue';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import conversationMixin from '../mixins/conversations';
import wootConstants from 'dashboard/constants/globals';
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
@@ -42,7 +41,7 @@ export default {
IntersectionObserver,
VirtualList,
},
mixins: [conversationMixin, filterMixin],
mixins: [filterMixin],
provide() {
return {
// Actions to be performed on virtual list item and context menu.
@@ -5,7 +5,7 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
import { isValidURL } from '../helper/URLHelper';
import customAttributeMixin from '../mixins/customAttributeMixin';
import { getRegexp } from 'shared/helpers/Validators';
import { useVuelidate } from '@vuelidate/core';
const DATE_FORMAT = 'yyyy-MM-dd';
@@ -15,7 +15,6 @@ export default {
MultiselectDropdown,
HelperTextPopup,
},
mixins: [customAttributeMixin],
props: {
label: { type: String, required: true },
description: { type: String, default: '' },
@@ -128,8 +127,7 @@ export default {
required,
regexValidation: value => {
return !(
this.attributeRegex &&
!this.getRegexp(this.attributeRegex).test(value)
this.attributeRegex && !getRegexp(this.attributeRegex).test(value)
);
},
},
@@ -1,8 +1,8 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useAccount } from 'dashboard/composables/useAccount';
import Banner from 'dashboard/components/ui/Banner.vue';
import accountMixin from 'dashboard/mixins/account';
const EMPTY_SUBSCRIPTION_INFO = {
status: null,
@@ -11,10 +11,13 @@ const EMPTY_SUBSCRIPTION_INFO = {
export default {
components: { Banner },
mixins: [accountMixin],
setup() {
const { isAdmin } = useAdmin();
const { accountId } = useAccount();
return {
accountId,
isAdmin,
};
},
@@ -1,12 +1,10 @@
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
import accountMixin from 'dashboard/mixins/account';
import { useAlert } from 'dashboard/composables';
export default {
components: { Banner },
mixins: [accountMixin],
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
@@ -1,12 +1,17 @@
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
import accountMixin from 'dashboard/mixins/account';
import { useAccount } from 'dashboard/composables/useAccount';
import { differenceInDays } from 'date-fns';
export default {
components: { Banner },
mixins: [accountMixin],
setup() {
const { accountId } = useAccount();
return {
accountId,
};
},
data() {
return { conversationMeta: {} };
},
@@ -54,7 +54,7 @@ const showAdditionalActions = computed(
);
const showOpenButton = computed(() => {
return isResolved.value || isSnoozed.value;
return isPending.value || isSnoozed.value;
});
const getConversationParams = () => {
@@ -208,7 +208,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
color-scheme="secondary"
size="small"
icon="book-clock"
@click="() => toggleStatus(STATUS_TYPE.PENDING)"
@click="() => toggleStatus(wootConstants.STATUS_TYPE.PENDING)"
>
{{ t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }}
</woot-button>
@@ -1,7 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import configMixin from 'shared/mixins/configMixin';
import { useConfig } from 'dashboard/composables/useConfig';
import {
getInboxClassByType,
getInboxWarningIconClass,
@@ -16,7 +16,6 @@ import Policy from '../../policy.vue';
export default {
components: { SecondaryChildNavItem, Policy },
mixins: [configMixin],
props: {
menuItem: {
type: Object,
@@ -25,8 +24,10 @@ export default {
},
setup() {
const { isAdmin } = useAdmin();
const { isEnterprise } = useConfig();
return {
isAdmin,
isEnterprise,
};
},
computed: {
@@ -101,6 +101,8 @@ const shouldShowEmptyState = computed(() => {
:key="item.id"
:is-active="isFilterActive(item.id)"
:button-text="item.name"
:icon="item.icon"
:icon-color="item.iconColor"
@click="$emit('click', item)"
/>
</slot>
@@ -8,6 +8,14 @@ defineProps({
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
},
iconColor: {
type: String,
default: '',
},
});
</script>
@@ -20,6 +28,12 @@ defineProps({
@focus="$emit('focus')"
>
<div class="inline-flex items-center gap-3 overflow-hidden">
<fluent-icon
v-if="icon"
:icon="icon"
size="18"
:style="{ color: iconColor }"
/>
<span
class="text-sm font-medium truncate text-slate-900 dark:text-slate-50"
>
@@ -116,29 +116,27 @@ export default {
setup() {
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 uploadTriggerButton = computed(() => {
if (uploadRef.value) {
return uploadRef.value.$children[1].$el;
}
return null;
});
const uploadRefElem = computed(() => uploadRef.value?.$el);
const keyboardEvents = {
'Alt+KeyA': {
action: () => {
uploadTriggerButton.value.click();
const uploadTriggerButton = document.querySelector(
'#conversationAttachment'
);
uploadTriggerButton.click();
},
allowOnFocusedInput: true,
},
};
watchEffect(() => {
useKeyboardEvents(keyboardEvents, uploadTriggerButton);
useKeyboardEvents(keyboardEvents, uploadRefElem);
});
return {
@@ -1,8 +1,8 @@
<script>
import { mapGetters } from 'vuex';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import Thumbnail from '../Thumbnail.vue';
import MessagePreview from './MessagePreview.vue';
import conversationMixin from '../../../mixins/conversations';
import router from '../../../routes';
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
import InboxName from '../InboxName.vue';
@@ -24,7 +24,7 @@ export default {
PriorityMark,
SLACardLabel,
},
mixins: [inboxMixin, conversationMixin],
mixins: [inboxMixin],
props: {
activeLabel: {
type: String,
@@ -118,7 +118,7 @@ export default {
},
lastMessageInChat() {
return this.lastMessage(this.chat);
return getLastMessage(this.chat);
},
inbox() {
@@ -316,7 +316,11 @@ export default {
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
</div>
<CardLabels :conversation-id="chat.id" class="mt-0.5 mx-2 mb-0">
<CardLabels
:conversation-id="chat.id"
:conversation-labels="chat.labels"
class="mt-0.5 mx-2 mb-0"
>
<template v-if="hasSlaPolicyId" #before>
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
@@ -1,7 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import accountMixin from 'dashboard/mixins/account';
import { useAccount } from 'dashboard/composables/useAccount';
import OnboardingView from '../OnboardingView.vue';
import EmptyStateMessage from './EmptyStateMessage.vue';
@@ -10,7 +10,6 @@ export default {
OnboardingView,
EmptyStateMessage,
},
mixins: [accountMixin],
props: {
isOnExpandedLayout: {
type: Boolean,
@@ -19,8 +18,12 @@ export default {
},
setup() {
const { isAdmin } = useAdmin();
const { accountScopedUrl } = useAccount();
return {
isAdmin,
accountScopedUrl,
};
},
computed: {
@@ -44,7 +47,7 @@ export default {
return this.$t('CONVERSATION.404');
},
newInboxURL() {
return this.addAccountScoping('settings/inboxes/new');
return this.accountScopedUrl('settings/inboxes/new');
},
emptyClassName() {
if (
@@ -1,6 +1,7 @@
<script>
import { ref } from 'vue';
// composable
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
// components
@@ -13,17 +14,18 @@ import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
// mixins
import conversationMixin, {
filterDuplicateSourceMessages,
} from '../../../mixins/conversations';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import configMixin from 'shared/mixins/configMixin';
import aiMixin from 'dashboard/mixins/aiMixin';
// utils
import { getTypingUsersText } from '../../../helper/commons';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
import { LocalStorage } from 'shared/helpers/localStorage';
import {
filterDuplicateSourceMessages,
getReadMessages,
getUnreadMessages,
} from 'dashboard/helper/conversationHelper';
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
@@ -38,7 +40,7 @@ export default {
Banner,
ConversationLabelSuggestion,
},
mixins: [conversationMixin, inboxMixin, configMixin, aiMixin],
mixins: [inboxMixin, aiMixin],
props: {
isContactPanelOpen: {
type: Boolean,
@@ -52,6 +54,7 @@ export default {
setup() {
const conversationFooterRef = ref(null);
const isPopOutReplyBox = ref(false);
const { isEnterprise } = useConfig();
const closePopOutReplyBox = () => {
isPopOutReplyBox.value = false;
@@ -70,6 +73,7 @@ export default {
useKeyboardEvents(keyboardEvents, conversationFooterRef);
return {
isEnterprise,
conversationFooterRef,
isPopOutReplyBox,
closePopOutReplyBox,
@@ -138,14 +142,14 @@ export default {
}
return messages;
},
getReadMessages() {
return this.readMessages(
readMessages() {
return getReadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
},
getUnReadMessages() {
return this.unReadMessages(
unReadMessages() {
return getUnreadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
@@ -468,7 +472,7 @@ export default {
</li>
</transition>
<Message
v-for="message in getReadMessages"
v-for="message in readMessages"
:key="message.id"
class="message--read ph-no-capture"
data-clarity-mask="True"
@@ -493,7 +497,7 @@ export default {
</span>
</li>
<Message
v-for="message in getUnReadMessages"
v-for="message in unReadMessages"
:key="message.id"
class="message--unread ph-no-capture"
data-clarity-mask="True"
@@ -1,19 +1,24 @@
<script>
import { mapGetters } from 'vuex';
import accountMixin from '../../../mixins/account';
import { useAccount } from 'dashboard/composables/useAccount';
export default {
mixins: [accountMixin],
setup() {
const { accountScopedUrl } = useAccount();
return {
accountScopedUrl,
};
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
newInboxURL() {
return this.addAccountScoping('settings/inboxes/new');
return this.accountScopedUrl('settings/inboxes/new');
},
newAgentURL() {
return this.addAccountScoping('settings/agents/list');
return this.accountScopedUrl('settings/agents/list');
},
newLabelsURL() {
return this.addAccountScoping('settings/labels/list');
return this.accountScopedUrl('settings/labels/list');
},
},
};
@@ -31,7 +31,6 @@ import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import rtlMixin from 'shared/mixins/rtlMixin';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
@@ -65,7 +64,6 @@ export default {
mixins: [
inboxMixin,
messageFormatterMixin,
rtlMixin,
fileUploadMixin,
keyboardEventListenerMixins,
],
@@ -121,6 +119,7 @@ export default {
},
computed: {
...mapGetters({
isRTL: 'accounts/isRTL',
currentChat: 'getSelectedChat',
messageSignature: 'getMessageSignature',
currentUser: 'getCurrentUser',
@@ -307,7 +306,7 @@ export default {
if (this.isOnExpandedLayout || this.popoutReplyBox) {
return 'emoji-dialog--expanded';
}
if (this.isRTLView) {
if (this.isRTL) {
return 'emoji-dialog--rtl';
}
return '';
@@ -1,23 +1,26 @@
<script>
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
export default {
mixins: [conversationLabelMixin],
props: {
// conversationId prop is used in /conversation/labelMixin,
// remove this props when refactoring to composable if not needed
// eslint-disable-next-line vue/no-unused-properties
conversationId: {
type: Number,
conversationLabels: {
type: Array,
required: true,
},
// conversationLabels prop is used in /conversation/labelMixin,
// remove this props when refactoring to composable if not needed
// eslint-disable-next-line vue/no-unused-properties
conversationLabels: {
type: String,
required: false,
default: '',
},
},
setup(props) {
const accountLabels = useMapGetter('labels/getLabels');
const activeLabels = computed(() => {
return accountLabels.value.filter(({ title }) =>
props.conversationLabels.includes(title)
);
});
return {
activeLabels,
};
},
data() {
return {
@@ -80,7 +83,7 @@ export default {
<slot name="before" />
<woot-label
v-for="(label, index) in activeLabels"
:key="label.id"
:key="label ? label.id : index"
:title="label.title"
:description="label.description"
:color="label.color"
@@ -62,6 +62,8 @@ const onSearch = async value => {
issues.value = response.data.map(issue => ({
id: issue.id,
name: `${issue.identifier} ${issue.title}`,
icon: 'status',
iconColor: issue.state.color,
}));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
@@ -0,0 +1,37 @@
import { ref } from 'vue';
import { describe, it, expect, vi } from 'vitest';
import { useAccount } from '../useAccount';
import { useStoreGetters } from 'dashboard/composables/store';
vi.mock('dashboard/composables/store');
describe('useAccount', () => {
beforeEach(() => {
useStoreGetters.mockReturnValue({
getCurrentAccountId: ref(123),
});
});
it('returns accountId as a computed property', () => {
const { accountId } = useAccount();
expect(accountId.value).toBe(123);
});
it('generates account-scoped URLs correctly', () => {
const { accountScopedUrl } = useAccount();
const result = accountScopedUrl('settings/inbox/new');
expect(result).toBe('/app/accounts/123/settings/inbox/new');
});
it('handles URLs with leading slash', () => {
const { accountScopedUrl } = useAccount();
const result = accountScopedUrl('users');
expect(result).toBe('/app/accounts/123/users');
});
it('handles empty URL', () => {
const { accountScopedUrl } = useAccount();
const result = accountScopedUrl('');
expect(result).toBe('/app/accounts/123/');
});
});
@@ -0,0 +1,51 @@
import { useConfig } from '../useConfig';
describe('useConfig', () => {
const originalChatwootConfig = window.chatwootConfig;
beforeEach(() => {
window.chatwootConfig = {
hostURL: 'https://example.com',
vapidPublicKey: 'vapid-key',
enabledLanguages: ['en', 'fr'],
isEnterprise: 'true',
enterprisePlanName: 'enterprise',
};
});
afterEach(() => {
window.chatwootConfig = originalChatwootConfig;
});
it('returns the correct configuration values', () => {
const config = useConfig();
expect(config.hostURL).toBe('https://example.com');
expect(config.vapidPublicKey).toBe('vapid-key');
expect(config.enabledLanguages).toEqual(['en', 'fr']);
expect(config.isEnterprise).toBe(true);
expect(config.enterprisePlanName).toBe('enterprise');
});
it('handles missing configuration values', () => {
window.chatwootConfig = {};
const config = useConfig();
expect(config.hostURL).toBeUndefined();
expect(config.vapidPublicKey).toBeUndefined();
expect(config.enabledLanguages).toBeUndefined();
expect(config.isEnterprise).toBe(false);
expect(config.enterprisePlanName).toBeUndefined();
});
it('handles undefined window.chatwootConfig', () => {
window.chatwootConfig = undefined;
const config = useConfig();
expect(config.hostURL).toBeUndefined();
expect(config.vapidPublicKey).toBeUndefined();
expect(config.enabledLanguages).toBeUndefined();
expect(config.isEnterprise).toBe(false);
expect(config.enterprisePlanName).toBeUndefined();
});
});
@@ -0,0 +1,87 @@
import { useConversationLabels } from '../useConversationLabels';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
vi.mock('dashboard/composables/store');
describe('useConversationLabels', () => {
let store;
let getters;
beforeEach(() => {
store = {
getters: {
'conversationLabels/getConversationLabels': vi.fn(),
},
dispatch: vi.fn(),
};
getters = {
getSelectedChat: { value: { id: 1 } },
'labels/getLabels': {
value: [
{ id: 1, title: 'Label 1' },
{ id: 2, title: 'Label 2' },
{ id: 3, title: 'Label 3' },
],
},
};
useStore.mockReturnValue(store);
useStoreGetters.mockReturnValue(getters);
});
it('should return the correct computed properties', () => {
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
'Label 1',
'Label 2',
]);
const { accountLabels, savedLabels, activeLabels, inactiveLabels } =
useConversationLabels();
expect(accountLabels.value).toEqual(getters['labels/getLabels'].value);
expect(savedLabels.value).toEqual(['Label 1', 'Label 2']);
expect(activeLabels.value).toEqual([
{ id: 1, title: 'Label 1' },
{ id: 2, title: 'Label 2' },
]);
expect(inactiveLabels.value).toEqual([{ id: 3, title: 'Label 3' }]);
});
it('should update labels correctly', async () => {
const { onUpdateLabels } = useConversationLabels();
await onUpdateLabels(['Label 1', 'Label 3']);
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
conversationId: 1,
labels: ['Label 1', 'Label 3'],
});
});
it('should add a label to the conversation', () => {
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
'Label 1',
]);
const { addLabelToConversation } = useConversationLabels();
addLabelToConversation({ title: 'Label 2' });
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
conversationId: 1,
labels: ['Label 1', 'Label 2'],
});
});
it('should remove a label from the conversation', () => {
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
'Label 1',
'Label 2',
]);
const { removeLabelFromConversation } = useConversationLabels();
removeLabelFromConversation('Label 2');
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
conversationId: 1,
labels: ['Label 1'],
});
});
});
@@ -0,0 +1,175 @@
import { describe, it, expect, vi } from 'vitest';
import { useMacros } from '../useMacros';
import { useStoreGetters } from 'dashboard/composables/store';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/helper/automationHelper.js');
describe('useMacros', () => {
const mockLabels = [
{
id: 6,
title: 'sales',
description: 'sales team',
color: '#8EA20F',
show_on_sidebar: true,
},
{
id: 2,
title: 'billing',
description: 'billing',
color: '#4077DA',
show_on_sidebar: true,
},
{
id: 1,
title: 'snoozed',
description: 'Items marked for later',
color: '#D12F42',
show_on_sidebar: true,
},
{
id: 5,
title: 'mobile-app',
description: 'tech team',
color: '#2DB1CC',
show_on_sidebar: true,
},
{
id: 14,
title: 'human-resources-department-with-long-title',
description: 'Test',
color: '#FF6E09',
show_on_sidebar: true,
},
{
id: 22,
title: 'priority',
description: 'For important sales leads',
color: '#7E7CED',
show_on_sidebar: true,
},
];
const mockTeams = [
{
id: 1,
name: '⚙️ sales team',
description: 'This is our internal sales team',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
{
id: 2,
name: '🤷‍♂️ fayaz',
description: 'Test',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
{
id: 3,
name: '🇮🇳 apac sales',
description: 'Sales team for France Territory',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
];
const mockAgents = [
{
id: 1,
account_id: 1,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'john@doe.com',
available_name: 'John Doe',
name: 'John Doe',
role: 'agent',
thumbnail: 'https://example.com/image.png',
},
{
id: 9,
account_id: 1,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'clark@kent.com',
available_name: 'Clark Kent',
name: 'Clark Kent',
role: 'agent',
thumbnail: '',
},
];
beforeEach(() => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: mockLabels },
'teams/getTeams': { value: mockTeams },
'agents/getAgents': { value: mockAgents },
});
});
it('initializes computed properties correctly', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toHaveLength(mockLabels.length);
expect(getMacroDropdownValues('assign_team')).toHaveLength(
mockTeams.length
);
expect(getMacroDropdownValues('assign_agent')).toHaveLength(
mockAgents.length + 1
); // +1 for "Self"
});
it('returns teams for assign_team and send_email_to_team types', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('assign_team')).toEqual(mockTeams);
expect(getMacroDropdownValues('send_email_to_team')).toEqual(mockTeams);
});
it('returns agents with "Self" option for assign_agent type', () => {
const { getMacroDropdownValues } = useMacros();
const result = getMacroDropdownValues('assign_agent');
expect(result[0]).toEqual({ id: 'self', name: 'Self' });
expect(result.slice(1)).toEqual(mockAgents);
});
it('returns formatted labels for add_label and remove_label types', () => {
const { getMacroDropdownValues } = useMacros();
const expectedLabels = mockLabels.map(i => ({
id: i.title,
name: i.title,
}));
expect(getMacroDropdownValues('add_label')).toEqual(expectedLabels);
expect(getMacroDropdownValues('remove_label')).toEqual(expectedLabels);
});
it('returns PRIORITY_CONDITION_VALUES for change_priority type', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('change_priority')).toEqual(
PRIORITY_CONDITION_VALUES
);
});
it('returns an empty array for unknown types', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('unknown_type')).toEqual([]);
});
it('handles empty data correctly', () => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: [] },
'teams/getTeams': { value: [] },
'agents/getAgents': { value: [] },
});
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toEqual([]);
expect(getMacroDropdownValues('assign_team')).toEqual([]);
expect(getMacroDropdownValues('assign_agent')).toEqual([
{ id: 'self', name: 'Self' },
]);
});
});
@@ -0,0 +1,30 @@
import { computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
/**
* Composable for account-related operations.
* @returns {Object} An object containing account-related properties and methods.
*/
export function useAccount() {
const getters = useStoreGetters();
/**
* Computed property for the current account ID.
* @type {import('vue').ComputedRef<number>}
*/
const accountId = computed(() => getters.getCurrentAccountId.value);
/**
* Generates an account-scoped URL.
* @param {string} url - The URL to be scoped to the account.
* @returns {string} The account-scoped URL.
*/
const accountScopedUrl = url => {
return `/app/accounts/${accountId.value}/${url}`;
};
return {
accountId,
accountScopedUrl,
};
}
@@ -0,0 +1,46 @@
/**
* A function that provides access to various configuration values.
* @returns {Object} An object containing configuration values.
*/
export function useConfig() {
const config = window.chatwootConfig || {};
/**
* The host URL of the Chatwoot instance.
* @type {string|undefined}
*/
const hostURL = config.hostURL;
/**
* The VAPID public key for web push notifications.
* @type {string|undefined}
*/
const vapidPublicKey = config.vapidPublicKey;
/**
* An array of enabled languages in the Chatwoot instance.
* @type {string[]|undefined}
*/
const enabledLanguages = config.enabledLanguages;
/**
* Indicates whether the current instance is an enterprise version.
* @type {boolean}
*/
const isEnterprise = config.isEnterprise === 'true';
/**
* The name of the enterprise plan, if applicable.
* Returns "community" or "enterprise"
* @type {string|undefined}
*/
const enterprisePlanName = config.enterprisePlanName;
return {
hostURL,
vapidPublicKey,
enabledLanguages,
isEnterprise,
enterprisePlanName,
};
}
@@ -0,0 +1,101 @@
import { computed } from 'vue';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
/**
* Composable for managing conversation labels
* @returns {Object} An object containing methods and computed properties for conversation labels
*/
export function useConversationLabels() {
const store = useStore();
const getters = useStoreGetters();
/**
* The currently selected chat
* @type {import('vue').ComputedRef<Object>}
*/
const currentChat = computed(() => getters.getSelectedChat.value);
/**
* The ID of the current conversation
* @type {import('vue').ComputedRef<number|null>}
*/
const conversationId = computed(() => currentChat.value?.id);
/**
* All labels available for the account
* @type {import('vue').ComputedRef<Array>}
*/
const accountLabels = computed(() => getters['labels/getLabels'].value);
/**
* Labels currently saved to the conversation
* @type {import('vue').ComputedRef<Array>}
*/
const savedLabels = computed(() => {
return store.getters['conversationLabels/getConversationLabels'](
conversationId.value
);
});
/**
* Labels currently active on the conversation
* @type {import('vue').ComputedRef<Array>}
*/
const activeLabels = computed(() =>
accountLabels.value.filter(({ title }) => savedLabels.value.includes(title))
);
/**
* Labels available but not active on the conversation
* @type {import('vue').ComputedRef<Array>}
*/
const inactiveLabels = computed(() =>
accountLabels.value.filter(
({ title }) => !savedLabels.value.includes(title)
)
);
/**
* Updates the labels for the current conversation
* @param {string[]} selectedLabels - Array of label titles to be set for the conversation
* @returns {Promise<void>}
*/
const onUpdateLabels = async selectedLabels => {
await store.dispatch('conversationLabels/update', {
conversationId: conversationId.value,
labels: selectedLabels,
});
};
/**
* Adds a label to the current conversation
* @param {Object} value - The label object to be added
* @param {string} value.title - The title of the label to be added
*/
const addLabelToConversation = value => {
const result = activeLabels.value.map(item => item.title);
result.push(value.title);
onUpdateLabels(result);
};
/**
* Removes a label from the current conversation
* @param {string} value - The title of the label to be removed
*/
const removeLabelFromConversation = value => {
const result = activeLabels.value
.map(label => label.title)
.filter(label => label !== value);
onUpdateLabels(result);
};
return {
accountLabels,
savedLabels,
activeLabels,
inactiveLabels,
addLabelToConversation,
removeLabelFromConversation,
onUpdateLabels,
};
}
@@ -0,0 +1,44 @@
import { computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js';
/**
* Composable for handling macro-related functionality
* @returns {Object} An object containing the getMacroDropdownValues function
*/
export const useMacros = () => {
const getters = useStoreGetters();
const labels = computed(() => getters['labels/getLabels'].value);
const teams = computed(() => getters['teams/getTeams'].value);
const agents = computed(() => getters['agents/getAgents'].value);
/**
* Get dropdown values based on the specified type
* @param {string} type - The type of dropdown values to retrieve
* @returns {Array} An array of dropdown values
*/
const getMacroDropdownValues = type => {
switch (type) {
case 'assign_team':
case 'send_email_to_team':
return teams.value;
case 'assign_agent':
return [{ id: 'self', name: 'Self' }, ...agents.value];
case 'add_label':
case 'remove_label':
return labels.value.map(i => ({
id: i.title,
name: i.title,
}));
case 'change_priority':
return PRIORITY_CONDITION_VALUES;
default:
return [];
}
};
return {
getMacroDropdownValues,
};
};
@@ -0,0 +1,94 @@
/**
* Determines the last non-activity message between store and API messages.
* @param {Object} messageInStore - The last non-activity message from the store.
* @param {Object} messageFromAPI - The last non-activity message from the API.
* @returns {Object} The latest non-activity message.
*/
const getLastNonActivityMessage = (messageInStore, messageFromAPI) => {
// If both API value and store value for last non activity message
// are available, then return the latest one.
if (messageInStore && messageFromAPI) {
return messageInStore.created_at >= messageFromAPI.created_at
? messageInStore
: messageFromAPI;
}
// Otherwise, return whichever is available
return messageInStore || messageFromAPI;
};
/**
* Filters out duplicate source messages from an array of messages.
* @param {Array} messages - The array of messages to filter.
* @returns {Array} An array of messages without duplicates.
*/
export const filterDuplicateSourceMessages = (messages = []) => {
const messagesWithoutDuplicates = [];
// We cannot use Map or any short hand method as it returns the last message with the duplicate ID
// We should return the message with smaller id when there is a duplicate
messages.forEach(m1 => {
if (m1.source_id) {
const index = messagesWithoutDuplicates.findIndex(
m2 => m1.source_id === m2.source_id
);
if (index < 0) {
messagesWithoutDuplicates.push(m1);
}
} else {
messagesWithoutDuplicates.push(m1);
}
});
return messagesWithoutDuplicates;
};
/**
* Retrieves the last message from a conversation, prioritizing non-activity messages.
* @param {Object} m - The conversation object containing messages.
* @returns {Object} The last message of the conversation.
*/
export const getLastMessage = m => {
const lastMessageIncludingActivity = m.messages[m.messages.length - 1];
const nonActivityMessages = m.messages.filter(
message => message.message_type !== 2
);
const lastNonActivityMessageInStore =
nonActivityMessages[nonActivityMessages.length - 1];
const lastNonActivityMessageFromAPI = m.last_non_activity_message;
// If API value and store value for last non activity message
// is empty, then return the last activity message
if (!lastNonActivityMessageInStore && !lastNonActivityMessageFromAPI) {
return lastMessageIncludingActivity;
}
return getLastNonActivityMessage(
lastNonActivityMessageInStore,
lastNonActivityMessageFromAPI
);
};
/**
* Filters messages that have been read by the agent.
* @param {Array} messages - The array of messages to filter.
* @param {number} agentLastSeenAt - The timestamp of when the agent last saw the messages.
* @returns {Array} An array of read messages.
*/
export const getReadMessages = (messages, agentLastSeenAt) => {
return messages.filter(
message => message.created_at * 1000 <= agentLastSeenAt * 1000
);
};
/**
* Filters messages that have not been read by the agent.
* @param {Array} messages - The array of messages to filter.
* @param {number} agentLastSeenAt - The timestamp of when the agent last saw the messages.
* @returns {Array} An array of unread messages.
*/
export const getUnreadMessages = (messages, agentLastSeenAt) => {
return messages.filter(
message => message.created_at * 1000 > agentLastSeenAt * 1000
);
};
@@ -1,5 +1,6 @@
const FEATURE_HELP_URLS = {
agent_bots: 'https://chwt.app/hc/agent-bots',
agents: 'https://chwt.app/hc/agents',
audit_logs: 'https://chwt.app/hc/audit-logs',
campaigns: 'https://chwt.app/hc/campaigns',
canned_responses: 'https://chwt.app/hc/canned',
@@ -0,0 +1,101 @@
import {
filterDuplicateSourceMessages,
getLastMessage,
getReadMessages,
getUnreadMessages,
} from '../conversationHelper';
import {
conversationData,
lastMessageData,
readMessagesData,
unReadMessagesData,
} from './fixtures/conversationFixtures';
describe('conversationHelper', () => {
describe('#filterDuplicateSourceMessages', () => {
it('returns messages without duplicate source_id and all messages without source_id', () => {
const input = [
{ source_id: null, id: 1 },
{ source_id: '', id: 2 },
{ id: 3 },
{ source_id: 'wa_1', id: 4 },
{ source_id: 'wa_1', id: 5 },
{ source_id: 'wa_1', id: 6 },
{ source_id: 'wa_2', id: 7 },
{ source_id: 'wa_2', id: 8 },
{ source_id: 'wa_3', id: 9 },
];
const expected = [
{ source_id: null, id: 1 },
{ source_id: '', id: 2 },
{ id: 3 },
{ source_id: 'wa_1', id: 4 },
{ source_id: 'wa_2', id: 7 },
{ source_id: 'wa_3', id: 9 },
];
expect(filterDuplicateSourceMessages(input)).toEqual(expected);
});
});
describe('#readMessages', () => {
it('should return read messages if conversation is passed', () => {
expect(
getReadMessages(
conversationData.messages,
conversationData.agent_last_seen_at
)
).toEqual(readMessagesData);
});
});
describe('#unReadMessages', () => {
it('should return unread messages if conversation is passed', () => {
expect(
getUnreadMessages(
conversationData.messages,
conversationData.agent_last_seen_at
)
).toEqual(unReadMessagesData);
});
});
describe('#lastMessage', () => {
it("should return last activity message if both api and store doesn't have other messages", () => {
const testConversation = {
messages: [conversationData.messages[0]],
last_non_activity_message: null,
};
expect(getLastMessage(testConversation)).toEqual(
testConversation.messages[0]
);
});
it('should return message from store if store has latest message', () => {
const testConversation = {
messages: [],
last_non_activity_message: lastMessageData,
};
expect(getLastMessage(testConversation)).toEqual(lastMessageData);
});
it('should return last non activity message from store if api value is empty', () => {
const testConversation = {
messages: [conversationData.messages[0], conversationData.messages[1]],
last_non_activity_message: null,
};
expect(getLastMessage(testConversation)).toEqual(
testConversation.messages[1]
);
});
it("should return last non activity message from store if store doesn't have any messages", () => {
const testConversation = {
messages: [conversationData.messages[1], conversationData.messages[2]],
last_non_activity_message: conversationData.messages[0],
};
expect(getLastMessage(testConversation)).toEqual(
testConversation.messages[1]
);
});
});
});
@@ -0,0 +1,185 @@
export const conversationData = {
meta: {
sender: {
additional_attributes: {
created_at_ip: '127.0.0.1',
},
availability_status: 'offline',
email: null,
id: 5017687,
name: 'long-flower-143',
phone_number: null,
thumbnail: '',
custom_attributes: {},
},
channel: 'Channel::WebWidget',
assignee: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'muhsin@chatwoot.com',
available_name: 'Muhsin Keloth',
id: 21,
name: 'Muhsin Keloth',
role: 'administrator',
thumbnail: 'http://example.com/image.png',
},
},
id: 5815,
messages: [
{
id: 438072,
content: 'Campaign after 5 seconds',
account_id: 1,
inbox_id: 37,
conversation_id: 5811,
message_type: 1,
created_at: 1620980262,
updated_at: '2021-05-14T08:17:42.041Z',
private: false,
status: 'sent',
source_id: null,
content_type: null,
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
},
{
id: 4382131101,
content: 'Hello',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
},
{
id: 438100,
content: 'Hey',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
},
],
inbox_id: 37,
status: 'open',
muted: false,
can_reply: true,
timestamp: 1621144123,
contact_last_seen_at: 0,
agent_last_seen_at: 1621144123,
unread_count: 0,
additional_attributes: {
browser: {
device_name: 'Unknown',
browser_name: 'Chrome',
platform_name: 'macOS',
browser_version: '90.0.4430.212',
platform_version: '10.15.7',
},
widget_language: null,
browser_language: 'en',
},
account_id: 1,
labels: [],
};
export const lastMessageData = {
id: 438100,
content: 'Hey',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
};
export const readMessagesData = [
{
id: 438072,
content: 'Campaign after 5 seconds',
account_id: 1,
inbox_id: 37,
conversation_id: 5811,
message_type: 1,
created_at: 1620980262,
updated_at: '2021-05-14T08:17:42.041Z',
private: false,
status: 'sent',
source_id: null,
content_type: null,
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
},
];
export const unReadMessagesData = [
{
id: 4382131101,
content: 'Hello',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
},
{
id: 438100,
content: 'Hey',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
},
];
@@ -3,7 +3,8 @@
"HEADER": "Agents",
"HEADER_BTN_TXT": "Add Agent",
"LOADING": "Fetching Agent List",
"SIDEBAR_TXT": "<p><b>Agents</b></p> <p> An <b>Agent</b> is a member of your Customer Support team. </p><p> Agents will be able to view and reply to messages from your users. The list shows all agents currently in your account. </p><p> Click on <b>Add Agent</b> to add a new agent. Agent you add will receive an email with a confirmation link to activate their account, after which they can access Chatwoot and respond to messages. </p><p> Access to Chatwoot's features are based on following roles. </p><p> <b>Agent</b> - Agents with this role can only access inboxes, reports and conversations. They can assign conversations to other agents or themselves and resolve conversations.</p><p> <b>Administrator</b> - Administrator will have access to all Chatwoot features enabled for your account, including settings, along with all of a normal agents' privileges.</p>",
"DESCRIPTION": "An agent is a member of your customer support team who can view and respond to user messages. The list below shows all the agents in your account.",
"LEARN_MORE": "Learn about user roles",
"AGENT_TYPES": {
"ADMINISTRATOR": "Administrator",
"AGENT": "Agent"
@@ -33,7 +34,6 @@
"PLACEHOLDER": "Please select a role",
"ERROR": "Role is required"
},
"EMAIL": {
"LABEL": "Email Address",
"PLACEHOLDER": "Please enter an email address of the agent"
@@ -1,15 +1,20 @@
{
"CANNED_MGMT": {
"HEADER": "Canned Responses",
"LEARN_MORE": "Learn more about canned responses",
"DESCRIPTION": "Canned Responses are pre-written reply templates that help you quickly respond to a conversation. Agents can type the '/' character followed by the shortcode to insert a canned response during a conversation. ",
"HEADER_BTN_TXT": "Add canned response",
"LOADING": "Fetching canned responses...",
"SEARCH_404": "There are no items matching this query.",
"SIDEBAR_TXT": "<p><b>Canned Responses</b> </p><p> Canned Responses are pre-written reply templates that help you quickly respond to a conversation. To insert a canned response during a chat, agents can type a short code preceded by a '/' character. </p><p> You can manage your canned responses from this page or create new ones using the \"Add canned response\" button.</p><p>Open the <a target=\"_blank\" href=\"https://www.chatwoot.com/hc/chatwoot-user-guide-cloud-version/articles/1677501325-how-to-create-saved-reply-templates-with-canned-responses\">Canned Responses handbook</a> in another tab for a helping hand.</p><p>Also, check out the all-new <a href=\"https://www.chatwoot.com/tools/canned-responses-library\" target=\"_blank\">Canned Responses Library</a>.</p>",
"LIST": {
"404": "There are no canned responses available in this account.",
"TITLE": "Manage canned responses",
"DESC": "Canned Responses are predefined reply templates which can be used to quickly send out replies to conversations.",
"TABLE_HEADER": ["Short code", "Content", "Actions"]
"TABLE_HEADER": [
"Short code",
"Content",
"Actions"
]
},
"ADD": {
"TITLE": "Add canned response",
@@ -3,13 +3,18 @@
"HEADER": "Labels",
"HEADER_BTN_TXT": "Add label",
"LOADING": "Fetching labels",
"DESCRIPTION": "Labels help you categorize and prioritize conversations and leads. You can assign a label to a conversation or contact using the side panel.",
"LEARN_MORE": "Learn more about labels",
"SEARCH_404": "There are no items matching this query",
"SIDEBAR_TXT": "<p><b>Labels</b> <p>Labels help you to categorize conversations and prioritize them. You can assign label to a conversation from the sidepanel. <br /><br />Labels are tied to the account and can be used to create custom workflows in your organization. You can assign custom color to a label, it makes it easier to identify the label. You will be able to display the label on the sidebar to filter the conversations easily.</p>",
"LIST": {
"404": "There are no labels available in this account.",
"TITLE": "Manage labels",
"DESC": "Labels let you group the conversations together.",
"TABLE_HEADER": ["Name", "Description", "Color"]
"TABLE_HEADER": [
"Name",
"Description",
"Color"
]
},
"FORM": {
"NAME": {
@@ -7,7 +7,8 @@
"LEARN_MORE": "Learn more about teams",
"LIST": {
"404": "There are no teams created on this account.",
"EDIT_TEAM": "Edit team"
"EDIT_TEAM": "Edit team",
"NONE": "None"
},
"CREATE_FLOW": {
"CREATE": {
@@ -1,14 +0,0 @@
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
}),
},
methods: {
addAccountScoping(url) {
return `/app/accounts/${this.accountId}/${url}`;
},
},
};
@@ -1,49 +0,0 @@
import { mapGetters } from 'vuex';
import { isValidURL } from '../helper/URLHelper';
export default {
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
accountId: 'getCurrentAccountId',
}),
attributes() {
return this.$store.getters['attributes/getAttributesByModel'](
this.attributeType
);
},
customAttributes() {
if (this.attributeType === 'conversation_attribute')
return this.currentChat.custom_attributes || {};
return this.contact.custom_attributes || {};
},
contactIdentifier() {
return (
this.currentChat.meta?.sender?.id ||
this.$route.params.contactId ||
this.contactId
);
},
contact() {
return this.$store.getters['contacts/getContact'](this.contactIdentifier);
},
conversationId() {
return this.currentChat.id;
},
},
methods: {
isAttributeNumber(attributeValue) {
return (
Number.isInteger(Number(attributeValue)) && Number(attributeValue) > 0
);
},
attributeDisplayType(attributeValue) {
if (this.isAttributeNumber(attributeValue)) {
return 'number';
}
if (isValidURL(attributeValue)) {
return 'link';
}
return 'text';
},
},
};
@@ -1,46 +0,0 @@
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters({ accountLabels: 'labels/getLabels' }),
savedLabels() {
// If conversationLabels is passed as prop, use it
if (this.conversationLabels)
return this.conversationLabels.split(',').map(item => item.trim());
// Otherwise, get labels from store
return this.$store.getters['conversationLabels/getConversationLabels'](
this.conversationId
);
},
// TODO - Get rid of this from the mixin
activeLabels() {
return this.accountLabels.filter(({ title }) =>
this.savedLabels.includes(title)
);
},
inactiveLabels() {
return this.accountLabels.filter(
({ title }) => !this.savedLabels.includes(title)
);
},
},
methods: {
addLabelToConversation(value) {
const result = this.activeLabels.map(item => item.title);
result.push(value.title);
this.onUpdateLabels(result);
},
removeLabelFromConversation(value) {
const result = this.activeLabels
.map(label => label.title)
.filter(label => label !== value);
this.onUpdateLabels(result);
},
async onUpdateLabels(selectedLabels) {
this.$store.dispatch('conversationLabels/update', {
conversationId: this.conversationId,
labels: selectedLabels,
});
},
},
};
@@ -1,22 +0,0 @@
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters({ teams: 'teams/getTeams' }),
hasAnAssignedTeam() {
return !!this.currentChat?.meta?.team;
},
teamsList() {
if (this.hasAnAssignedTeam) {
return [
{
id: 0,
name: 'None',
},
...this.teams,
];
}
return this.teams;
},
},
};
@@ -1,68 +0,0 @@
const getLastNonActivityMessage = (messageInStore, messageFromAPI) => {
// If both API value and store value for last non activity message
// are available, then return the latest one.
if (messageInStore && messageFromAPI) {
if (messageInStore.created_at >= messageFromAPI.created_at) {
return messageInStore;
}
return messageFromAPI;
}
// Otherwise, return whichever is available
return messageInStore || messageFromAPI;
};
export const filterDuplicateSourceMessages = (messages = []) => {
const messagesWithoutDuplicates = [];
// We cannot use Map or any short hand method as it returns the last message with the duplicate ID
// We should return the message with smaller id when there is a duplicate
messages.forEach(m1 => {
if (m1.source_id) {
if (
messagesWithoutDuplicates.findIndex(
m2 => m1.source_id === m2.source_id
) < 0
) {
messagesWithoutDuplicates.push(m1);
}
} else {
messagesWithoutDuplicates.push(m1);
}
});
return messagesWithoutDuplicates;
};
export default {
methods: {
lastMessage(m) {
let lastMessageIncludingActivity = m.messages.last();
const nonActivityMessages = m.messages.filter(
message => message.message_type !== 2
);
let lastNonActivityMessageInStore = nonActivityMessages.last();
let lastNonActivityMessageFromAPI = m.last_non_activity_message;
// If API value and store value for last non activity message
// is empty, then return the last activity message
if (!lastNonActivityMessageInStore && !lastNonActivityMessageFromAPI) {
return lastMessageIncludingActivity;
}
return getLastNonActivityMessage(
lastNonActivityMessageInStore,
lastNonActivityMessageFromAPI
);
},
readMessages(messages, agentLastSeenAt) {
return messages.filter(
message => message.created_at * 1000 <= agentLastSeenAt * 1000
);
},
unReadMessages(messages, agentLastSeenAt) {
return messages.filter(
message => message.created_at * 1000 > agentLastSeenAt * 1000
);
},
},
};
@@ -1,11 +0,0 @@
export default {
methods: {
getRegexp(regexPatternValue) {
let lastSlash = regexPatternValue.lastIndexOf('/');
return new RegExp(
regexPatternValue.slice(1, lastSlash),
regexPatternValue.slice(lastSlash + 1)
);
},
},
};
@@ -1,34 +0,0 @@
import { mapGetters } from 'vuex';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js';
export default {
computed: {
...mapGetters({
labels: 'labels/getLabels',
teams: 'teams/getTeams',
agents: 'agents/getAgents',
}),
},
methods: {
getDropdownValues(type) {
switch (type) {
case 'assign_team':
case 'send_email_to_team':
return this.teams;
case 'assign_agent':
return [{ id: 'self', name: 'Self' }, ...this.agents];
case 'add_label':
case 'remove_label':
return this.labels.map(i => {
return {
id: i.title,
name: i.title,
};
});
case 'change_priority':
return PRIORITY_CONDITION_VALUES;
default:
return [];
}
},
},
};
@@ -1,42 +0,0 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import accountMixin from '../account';
import Vuex from 'vuex';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('accountMixin', () => {
let getters;
let store;
beforeEach(() => {
getters = {
getCurrentAccountId: () => 1,
};
store = new Vuex.Store({ getters });
});
it('set accountId properly', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [accountMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.accountId).toBe(1);
});
it('returns current url', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [accountMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.addAccountScoping('settings/inboxes/new')).toBe(
'/app/accounts/1/settings/inboxes/new'
);
});
});
@@ -1,145 +0,0 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import attributeMixin from '../attributeMixin';
import Vuex from 'vuex';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('attributeMixin', () => {
let getters;
let actions;
let store;
beforeEach(() => {
actions = { updateUISettings: vi.fn(), toggleSidebarUIState: vi.fn() };
getters = {
getSelectedChat: () => ({
id: 7165,
custom_attributes: {
product_id: 2021,
},
meta: {
sender: {
id: 1212,
},
},
}),
getCurrentAccountId: () => 1,
attributeType: () => 'conversation_attribute',
};
store = new Vuex.Store({ actions, getters });
});
it('returns currently selected conversation id', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [attributeMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.conversationId).toEqual(7165);
});
it('return display type if attribute passed', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [attributeMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.attributeDisplayType('date')).toBe('text');
expect(
wrapper.vm.attributeDisplayType('https://www.chatwoot.com/pricing')
).toBe('link');
expect(wrapper.vm.attributeDisplayType(9988)).toBe('number');
});
it('return true if number is passed', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [attributeMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.isAttributeNumber(9988)).toBe(true);
});
it('returns currently selected contact', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [attributeMixin],
computed: {
contact() {
return {
id: 7165,
custom_attributes: {
product_id: 2021,
},
};
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.contact).toEqual({
id: 7165,
custom_attributes: {
product_id: 2021,
},
});
});
it('returns currently selected contact id', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [attributeMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.contactIdentifier).toEqual(1212);
});
it('returns currently selected conversation custom attributes', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [attributeMixin],
computed: {
contact() {
return {
id: 7165,
custom_attributes: {
product_id: 2021,
},
};
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.customAttributes).toEqual({
product_id: 2021,
});
});
it('returns currently selected contact custom attributes', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [attributeMixin],
computed: {
contact() {
return {
id: 7165,
custom_attributes: {
cloudCustomer: true,
},
};
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.customAttributes).toEqual({
cloudCustomer: true,
});
});
});
@@ -1,126 +0,0 @@
import conversationMixin, {
filterDuplicateSourceMessages,
} from '../conversations';
import conversationFixture from './conversationFixtures';
import commonHelpers from '../../helper/commons';
commonHelpers();
describe('#filterDuplicateSourceMessages', () => {
it('returns messages without duplicate source_id and all messages without source_id', () => {
expect(
filterDuplicateSourceMessages([
{ source_id: null, id: 1 },
{ source_id: '', id: 2 },
{ id: 3 },
{ source_id: 'wa_1', id: 4 },
{ source_id: 'wa_1', id: 5 },
{ source_id: 'wa_1', id: 6 },
{ source_id: 'wa_2', id: 7 },
{ source_id: 'wa_2', id: 8 },
{ source_id: 'wa_3', id: 9 },
])
).toEqual([
{ source_id: null, id: 1 },
{ source_id: '', id: 2 },
{ id: 3 },
{ source_id: 'wa_1', id: 4 },
{ source_id: 'wa_2', id: 7 },
{ source_id: 'wa_3', id: 9 },
]);
});
});
describe('#conversationMixin', () => {
it('should return read messages if conversation is passed', () => {
expect(
conversationMixin.methods.readMessages(
conversationFixture.conversation.messages,
conversationFixture.conversation.agent_last_seen_at
)
).toEqual(conversationFixture.readMessages);
});
it('should return read messages if conversation is passed', () => {
expect(
conversationMixin.methods.unReadMessages(
conversationFixture.conversation.messages,
conversationFixture.conversation.agent_last_seen_at
)
).toEqual(conversationFixture.unReadMessages);
});
describe('#lastMessage', () => {
it("should return last activity message if both api and store doesn't have other messages", () => {
const conversation = {
messages: [
{ id: 1, created_at: 1654333, message_type: 2, content: 'Hey' },
],
last_non_activity_message: null,
};
const { messages } = conversation;
expect(conversationMixin.methods.lastMessage(conversation)).toEqual(
messages[messages.length - 1]
);
});
it('should return message from store if store has latest message', () => {
const conversation = {
messages: [],
last_non_activity_message: {
id: 2,
created_at: 1654334,
message_type: 2,
content: 'Hey',
},
};
expect(conversationMixin.methods.lastMessage(conversation)).toEqual(
conversation.last_non_activity_message
);
});
it('should return last non activity message from store if api value is empty', () => {
const conversation = {
messages: [
{
id: 1,
created_at: 1654333,
message_type: 1,
content: 'Outgoing Message',
},
{ id: 2, created_at: 1654334, message_type: 2, content: 'Hey' },
],
last_non_activity_message: null,
};
expect(conversationMixin.methods.lastMessage(conversation)).toEqual(
conversation.messages[0]
);
});
it("should return last non activity message from store if store doesn't have any messages", () => {
const conversation = {
messages: [
{
id: 1,
created_at: 1654333,
message_type: 1,
content: 'Outgoing Message',
},
{
id: 3,
created_at: 1654335,
message_type: 0,
content: 'Incoming Message',
},
],
last_non_activity_message: {
id: 2,
created_at: 1654334,
message_type: 2,
content: 'Hey',
},
};
expect(conversationMixin.methods.lastMessage(conversation)).toEqual(
conversation.messages[1]
);
});
});
});
@@ -1,185 +0,0 @@
export default {
conversation: {
meta: {
sender: {
additional_attributes: {
created_at_ip: '127.0.0.1',
},
availability_status: 'offline',
email: null,
id: 5017687,
name: 'long-flower-143',
phone_number: null,
thumbnail: '',
custom_attributes: {},
},
channel: 'Channel::WebWidget',
assignee: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'muhsin@chatwoot.com',
available_name: 'Muhsin Keloth',
id: 21,
name: 'Muhsin Keloth',
role: 'administrator',
thumbnail:
'http://0.0.0.0:3000/rails/active_storage/representations/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBEQT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--7b95641540fadebc733ec9b42117d00bc09600be/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCam9MY21WemFYcGxTU0lNTWpVd2VESTFNQVk2QmtWVSIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--c13bd5229b2a2a692444606e22f76ad61c634661/me.jpg',
},
},
id: 5815,
messages: [
{
id: 438072,
content: 'Campaign after 5 seconds',
account_id: 1,
inbox_id: 37,
conversation_id: 5811,
message_type: 1,
created_at: 1620980262,
updated_at: '2021-05-14T08:17:42.041Z',
private: false,
status: 'sent',
source_id: null,
content_type: null,
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
},
{
id: 4382131101,
content: 'Hello',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
},
{
id: 438100,
content: 'Hey',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
},
],
inbox_id: 37,
status: 'open',
muted: false,
can_reply: true,
timestamp: 1621144123,
contact_last_seen_at: 0,
agent_last_seen_at: 1621144123,
unread_count: 0,
additional_attributes: {
browser: {
device_name: 'Unknown',
browser_name: 'Chrome',
platform_name: 'macOS',
browser_version: '90.0.4430.212',
platform_version: '10.15.7',
},
widget_language: null,
browser_language: 'en',
},
account_id: 1,
labels: [],
},
lastMessage: {
id: 438100,
content: 'Hey',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
},
readMessages: [
{
id: 438072,
content: 'Campaign after 5 seconds',
account_id: 1,
inbox_id: 37,
conversation_id: 5811,
message_type: 1,
created_at: 1620980262,
updated_at: '2021-05-14T08:17:42.041Z',
private: false,
status: 'sent',
source_id: null,
content_type: null,
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
},
],
unReadMessages: [
{
id: 4382131101,
content: 'Hello',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
},
{
id: 438100,
content: 'Hey',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
},
],
};
@@ -1,50 +0,0 @@
import { createWrapper } from '@vue/test-utils';
import macrosMixin from '../macrosMixin';
import Vue from 'vue';
import { teams, labels, agents } from '../../helper/specs/macrosFixtures';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js';
describe('webhookMixin', () => {
describe('#getEventLabel', () => {
it('returns correct i18n translation:', () => {
const Component = {
render() {},
title: 'MyComponent',
mixins: [macrosMixin],
data: () => {
return {
teams,
labels,
agents,
};
},
methods: {
$t(text) {
return text;
},
},
};
const resolvedLabels = labels.map(i => {
return {
id: i.title,
name: i.title,
};
});
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
expect(wrapper.vm.getDropdownValues('assign_team')).toEqual(teams);
expect(wrapper.vm.getDropdownValues('send_email_to_team')).toEqual(teams);
expect(wrapper.vm.getDropdownValues('add_label')).toEqual(resolvedLabels);
expect(wrapper.vm.getDropdownValues('assign_agent')).toEqual([
{ id: 'self', name: 'Self' },
...agents,
]);
expect(wrapper.vm.getDropdownValues('change_priority')).toEqual(
PRIORITY_CONDITION_VALUES
);
expect(wrapper.vm.getDropdownValues()).toEqual([]);
});
});
});
@@ -1,43 +1,34 @@
<script>
<script setup>
import { ref, computed } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
components: {
WootMessageEditor,
},
mixins: [keyboardEventListenerMixins],
data() {
return {
noteContent: '',
};
},
computed: {
buttonDisabled() {
return this.noteContent === '';
},
},
methods: {
getKeyboardEvents() {
return {
'$mod+Enter': {
action: () => this.onAdd(),
allowOnFocusedInput: true,
},
};
},
onAdd() {
if (this.noteContent !== '') {
this.$emit('add', this.noteContent);
}
this.noteContent = '';
},
const emit = defineEmits(['add']);
const addNoteRef = ref(null);
const noteContent = ref('');
const buttonDisabled = computed(() => noteContent.value === '');
const onAdd = () => {
if (noteContent.value !== '') {
emit('add', noteContent.value);
}
noteContent.value = '';
};
const keyboardEvents = {
'$mod+Enter': {
action: () => onAdd(),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, addNoteRef);
</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
@@ -0,0 +1,72 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'dashboard/composables/useI18n';
import { useEmitter } from 'dashboard/composables/emitter';
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { CMD_SNOOZE_CONVERSATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import wootConstants from 'dashboard/constants/globals';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
const showCustomSnoozeModal = ref(false);
const selectedChat = computed(() => getters.getSelectedChat.value);
const contextMenuChatId = computed(() => getters.getContextMenuChatId.value);
const toggleStatus = async (status, snoozedUntil) => {
await store.dispatch('toggleStatus', {
conversationId: selectedChat.value?.id || contextMenuChatId.value,
status,
snoozedUntil,
});
store.dispatch('setContextMenuChatId', null);
useAlert(t('CONVERSATION.CHANGE_STATUS'));
};
const onCmdSnoozeConversation = snoozeType => {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
showCustomSnoozeModal.value = true;
} else {
toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
findSnoozeTime(snoozeType) || null
);
}
};
const chooseSnoozeTime = customSnoozeTime => {
showCustomSnoozeModal.value = false;
if (customSnoozeTime) {
toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
getUnixTime(customSnoozeTime)
);
}
};
const hideCustomSnoozeModal = () => {
// if we select custom snooze and the custom snooze modal is open
// Then if the custom snooze modal is closed then set the context menu chat id to null
store.dispatch('setContextMenuChatId', null);
showCustomSnoozeModal.value = false;
};
useEmitter(CMD_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
</script>
<template>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@chooseTime="chooseSnoozeTime"
/>
</woot-modal>
</template>
@@ -1,6 +1,6 @@
<!-- eslint-disable vue/attribute-hyphenation -->
<script>
import '@chatwoot/ninja-keys';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import wootConstants from 'dashboard/constants/globals';
import conversationHotKeysMixin from './conversationHotKeys';
import bulkActionsHotKeysMixin from './bulkActionsHotKeys';
@@ -8,8 +8,6 @@ import inboxHotKeysMixin from './inboxHotKeys';
import goToCommandHotKeys from './goToCommandHotKeys';
import appearanceHotKeys from './appearanceHotKeys';
import agentMixin from 'dashboard/mixins/agentMixin';
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
import conversationTeamMixin from 'dashboard/mixins/conversation/teamMixin';
import { GENERAL_EVENTS } from '../../../helper/AnalyticsHelper/events';
export default {
@@ -18,11 +16,25 @@ export default {
conversationHotKeysMixin,
bulkActionsHotKeysMixin,
inboxHotKeysMixin,
conversationLabelMixin,
conversationTeamMixin,
appearanceHotKeys,
goToCommandHotKeys,
],
setup() {
// used in conversationHotKeysMixin
const {
activeLabels,
inactiveLabels,
addLabelToConversation,
removeLabelFromConversation,
} = useConversationLabels();
return {
activeLabels,
inactiveLabels,
addLabelToConversation,
removeLabelFromConversation,
};
},
data() {
return {
// Added selectedSnoozeType to track the selected snooze type
@@ -97,11 +109,12 @@ export default {
};
</script>
<!-- eslint-disable vue/attribute-hyphenation -->
<template>
<ninja-keys
ref="ninjakeys"
no-auto-load-md-icons
hide-breadcrumbs
noAutoLoadMdIcons
hideBreadcrumbs
:placeholder="placeholder"
@selected="onSelected"
@closed="onClosed"
@@ -65,6 +65,7 @@ export default {
currentChat: 'getSelectedChat',
replyMode: 'draftMessages/getReplyEditorMode',
contextMenuChatId: 'getContextMenuChatId',
teams: 'teams/getTeams',
}),
draftMessage() {
return this.$store.getters['draftMessages/get'](this.draftKey);
@@ -78,7 +79,18 @@ export default {
conversationId() {
return this.currentChat?.id;
},
hasAnAssignedTeam() {
return !!this.currentChat?.meta?.team;
},
teamsList() {
if (this.hasAnAssignedTeam) {
return [
{ id: 0, name: this.$t('TEAMS_SETTINGS.LIST.NONE') },
...this.teams,
];
}
return this.teams;
},
statusActions() {
const isOpen =
this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
@@ -53,7 +53,7 @@ const INBOX_SNOOZE_EVENTS = [
},
{
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
title: 'COMMAND_BAR.COMMANDS.CUSTOM',
title: 'COMMAND_BAR.COMMANDS.UNTIL_CUSTOM_TIME',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
@@ -115,13 +115,11 @@ export default {
<CustomAttributes
:contact-id="contact.id"
attribute-type="contact_attribute"
attribute-class="conversation--attribute"
attribute-from="contact_panel"
:custom-attributes="contact.custom_attributes"
:empty-state-message="
$t('CONTACT_PANEL.SIDEBAR_SECTIONS.NO_RECORDS_FOUND')
"
class="even"
/>
</AccordionItem>
</div>
@@ -1,4 +1,5 @@
<script>
import { mapGetters } from 'vuex';
import { VeTable } from 'vue-easytable';
import { getCountryFlag } from 'dashboard/helper/flag';
@@ -6,7 +7,6 @@ import Spinner from 'shared/components/Spinner.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import { dynamicTime } from 'shared/helpers/timeHelper';
import rtlMixin from 'shared/mixins/rtlMixin';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
export default {
@@ -15,7 +15,6 @@ export default {
Spinner,
VeTable,
},
mixins: [rtlMixin],
props: {
contacts: {
type: Array,
@@ -52,6 +51,9 @@ export default {
};
},
computed: {
...mapGetters({
isRTL: 'accounts/isRTL',
}),
tableData() {
if (this.isLoading) {
return [];
@@ -85,7 +87,7 @@ export default {
key: 'name',
title: this.$t('CONTACTS_PAGE.LIST.TABLE_HEADER.NAME'),
fixed: 'left',
align: this.isRTLView ? 'right' : 'left',
align: this.isRTL ? 'right' : 'left',
sortBy: this.sortConfig.name || '',
width: 300,
renderBodyCell: ({ row }) => (
@@ -121,7 +123,7 @@ export default {
field: 'email',
key: 'email',
title: this.$t('CONTACTS_PAGE.LIST.TABLE_HEADER.EMAIL_ADDRESS'),
align: this.isRTLView ? 'right' : 'left',
align: this.isRTL ? 'right' : 'left',
sortBy: this.sortConfig.email || '',
width: 240,
renderBodyCell: ({ row }) => {
@@ -145,27 +147,27 @@ export default {
key: 'phone_number',
sortBy: this.sortConfig.phone_number || '',
title: this.$t('CONTACTS_PAGE.LIST.TABLE_HEADER.PHONE_NUMBER'),
align: this.isRTLView ? 'right' : 'left',
align: this.isRTL ? 'right' : 'left',
},
{
field: 'company',
key: 'company',
sortBy: this.sortConfig.company_name || '',
title: this.$t('CONTACTS_PAGE.LIST.TABLE_HEADER.COMPANY'),
align: this.isRTLView ? 'right' : 'left',
align: this.isRTL ? 'right' : 'left',
},
{
field: 'city',
key: 'city',
sortBy: this.sortConfig.city || '',
title: this.$t('CONTACTS_PAGE.LIST.TABLE_HEADER.CITY'),
align: this.isRTLView ? 'right' : 'left',
align: this.isRTL ? 'right' : 'left',
},
{
field: 'country',
key: 'country',
title: this.$t('CONTACTS_PAGE.LIST.TABLE_HEADER.COUNTRY'),
align: this.isRTLView ? 'right' : 'left',
align: this.isRTL ? 'right' : 'left',
sortBy: this.sortConfig.country || '',
renderBodyCell: ({ row }) => {
if (row.country) {
@@ -182,7 +184,7 @@ export default {
field: 'profiles',
key: 'profiles',
title: this.$t('CONTACTS_PAGE.LIST.TABLE_HEADER.SOCIAL_PROFILES'),
align: this.isRTLView ? 'right' : 'left',
align: this.isRTL ? 'right' : 'left',
renderBodyCell: ({ row }) => {
const { profiles } = row;
@@ -213,14 +215,14 @@ export default {
key: 'last_activity_at',
sortBy: this.sortConfig.last_activity_at || '',
title: this.$t('CONTACTS_PAGE.LIST.TABLE_HEADER.LAST_ACTIVITY'),
align: this.isRTLView ? 'right' : 'left',
align: this.isRTL ? 'right' : 'left',
},
{
field: 'created_at',
key: 'created_at',
sortBy: this.sortConfig.created_at || '',
title: this.$t('CONTACTS_PAGE.LIST.TABLE_HEADER.CREATED_AT'),
align: this.isRTLView ? 'right' : 'left',
align: this.isRTL ? 'right' : 'left',
},
];
},
@@ -218,8 +218,6 @@ export default {
>
<CustomAttributes
attribute-type="contact_attribute"
attribute-class="conversation--attribute"
class="even"
attribute-from="conversation_contact_panel"
:contact-id="contact.id"
:empty-state-message="
@@ -6,7 +6,6 @@ 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 teamMixin from 'dashboard/mixins/conversation/teamMixin';
import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
@@ -16,7 +15,7 @@ export default {
MultiselectDropdown,
ConversationLabels,
},
mixins: [agentMixin, teamMixin],
mixins: [agentMixin],
props: {
conversationId: {
type: [Number, String],
@@ -65,7 +64,20 @@ export default {
...mapGetters({
currentChat: 'getSelectedChat',
currentUser: 'getCurrentUser',
teams: 'teams/getTeams',
}),
hasAnAssignedTeam() {
return !!this.currentChat?.meta?.team;
},
teamsList() {
if (this.hasAnAssignedTeam) {
return [
{ id: 0, name: this.$t('TEAMS_SETTINGS.LIST.NONE') },
...this.teams,
];
}
return this.teams;
},
assignedAgent: {
get() {
return this.currentChat.meta.assignee;
@@ -70,41 +70,39 @@ const staticElements = computed(() =>
},
].filter(attribute => !!attribute.content.value)
);
const evenClass = [
'[&>*:nth-child(odd)]:!bg-white [&>*:nth-child(even)]:!bg-slate-25',
'dark:[&>*:nth-child(odd)]:!bg-slate-900 dark:[&>*:nth-child(even)]:!bg-slate-800/50',
];
</script>
<template>
<div class="conversation--details">
<ContactDetailsItem
v-for="element in staticElements"
:key="element.title"
:title="$t(element.title)"
:value="element.content.value"
class="conversation--attribute"
>
<a
v-if="element.type === 'link'"
:href="referer"
rel="noopener noreferrer nofollow"
target="_blank"
class="text-woot-400 dark:text-woot-600"
<div :class="evenClass">
<ContactDetailsItem
v-for="element in staticElements"
:key="element.title"
:title="$t(element.title)"
:value="element.content.value"
class="border-b border-solid border-slate-50 dark:border-slate-700/50"
>
{{ referer }}
</a>
</ContactDetailsItem>
<a
v-if="element.type === 'link'"
:href="referer"
rel="noopener noreferrer nofollow"
target="_blank"
class="text-woot-400 dark:text-woot-600"
>
{{ referer }}
</a>
</ContactDetailsItem>
</div>
<CustomAttributes
:class="staticElements.length % 2 === 0 ? 'even' : 'odd'"
:start-at="staticElements.length % 2 === 0 ? 'even' : 'odd'"
attribute-class="conversation--attribute"
attribute-from="conversation_panel"
attribute-type="conversation_attribute"
/>
</div>
</template>
<style scoped lang="scss">
.conversation--attribute {
@apply border-slate-50 dark:border-slate-700/50 border-b border-solid;
&:nth-child(2n) {
@apply bg-slate-25 dark:bg-slate-800/50;
}
}
</style>
@@ -1,23 +1,19 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { getUnixTime } from 'date-fns';
import ChatList from '../../../components/ChatList.vue';
import ConversationBox from '../../../components/widgets/conversation/ConversationBox.vue';
import PopOverSearch from './search/PopOverSearch.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CMD_SNOOZE_CONVERSATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
export default {
components: {
ChatList,
ConversationBox,
PopOverSearch,
CustomSnoozeModal,
CmdBarConversationSnooze,
},
props: {
inboxId: {
@@ -56,14 +52,12 @@ export default {
data() {
return {
showSearchModal: false,
showCustomSnoozeModal: false,
};
},
computed: {
...mapGetters({
chatList: 'getAllConversations',
currentChat: 'getSelectedChat',
contextMenuChatId: 'getContextMenuChatId',
}),
showConversationList() {
return this.isOnExpandedLayout ? !this.conversationId : true;
@@ -100,10 +94,6 @@ export default {
this.$watch('chatList.length', () => {
this.setActiveChat();
});
this.$emitter.on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
},
beforeDestroy() {
this.$emitter.off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
},
methods: {
@@ -178,43 +168,6 @@ export default {
closeSearch() {
this.showSearchModal = false;
},
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
this.toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
findSnoozeTime(snoozeType) || null
);
}
},
chooseSnoozeTime(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
this.toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
getUnixTime(customSnoozeTime)
);
}
},
toggleStatus(status, snoozedUntil) {
this.$store
.dispatch('toggleStatus', {
conversationId: this.currentChat?.id || this.contextMenuChatId,
status,
snoozedUntil,
})
.then(() => {
this.$store.dispatch('setContextMenuChatId', null);
useAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
});
},
hideCustomSnoozeModal() {
// if we select custom snooze and the custom snooze modal is open
// Then if the custom snooze modal is closed then set the context menu chat id to null
this.$store.dispatch('setContextMenuChatId', null);
this.showCustomSnoozeModal = false;
},
},
};
</script>
@@ -243,15 +196,7 @@ export default {
:is-on-expanded-layout="isOnExpandedLayout"
@contactPanelToggle="onToggleContactPanel"
/>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@chooseTime="chooseSnoozeTime"
/>
</woot-modal>
<CmdBarConversationSnooze />
</section>
</template>
@@ -1,19 +1,25 @@
<script>
import { mapGetters } from 'vuex';
import { useAccount } from 'dashboard/composables/useAccount';
import MacroItem from './MacroItem.vue';
import accountMixin from 'dashboard/mixins/account.js';
export default {
components: {
MacroItem,
},
mixins: [accountMixin],
props: {
conversationId: {
type: [Number, String],
required: true,
},
},
setup() {
const { accountScopedUrl } = useAccount();
return {
accountScopedUrl,
};
},
computed: {
...mapGetters({
macros: ['macros/getMacros'],
@@ -32,10 +38,10 @@ export default {
v-if="!uiFlags.isFetching && !macros.length"
class="macros_list--empty-state"
>
<p class="flex h-full items-center flex-col justify-center">
<p class="flex flex-col items-center justify-center h-full">
{{ $t('MACROS.LIST.404') }}
</p>
<router-link :to="addAccountScoping('settings/macros')">
<router-link :to="accountScopedUrl('settings/macros')">
<woot-button
variant="smooth"
icon="add"
@@ -1,153 +1,187 @@
<script>
<script setup>
import { computed, onMounted } from 'vue';
import { useToggle } from '@vueuse/core';
import { useRoute } from 'dashboard/composables/route';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'dashboard/composables/useI18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import CustomAttribute from 'dashboard/components/CustomAttribute.vue';
import attributeMixin from 'dashboard/mixins/attributeMixin';
export default {
components: {
CustomAttribute,
const props = defineProps({
attributeType: {
type: String,
default: 'conversation_attribute',
},
mixins: [attributeMixin],
props: {
attributeType: {
type: String,
default: 'conversation_attribute',
},
attributeClass: {
type: String,
default: '',
},
contactId: { type: Number, default: null },
attributeFrom: {
type: String,
required: true,
},
emptyStateMessage: {
type: String,
default: '',
},
contactId: { type: Number, default: null },
attributeFrom: {
type: String,
required: true,
},
setup() {
const { uiSettings, updateUISettings } = useUISettings();
emptyStateMessage: {
type: String,
default: '',
},
startAt: {
type: String,
default: 'even',
validator: value => value === 'even' || value === 'odd',
},
});
const store = useStore();
const getters = useStoreGetters();
const route = useRoute();
const { t } = useI18n();
const { uiSettings, updateUISettings } = useUISettings();
const [showAllAttributes, toggleShowAllAttributes] = useToggle(false);
const currentChat = computed(() => getters.getSelectedChat.value);
const attributes = computed(() =>
getters['attributes/getAttributesByModel'].value(props.attributeType)
);
const contactIdentifier = computed(
() =>
currentChat.value.meta?.sender?.id ||
route.params.contactId ||
props.contactId
);
const contact = computed(() =>
getters['contacts/getContact'].value(contactIdentifier.value)
);
const customAttributes = computed(() => {
if (props.attributeType === 'conversation_attribute')
return currentChat.value.custom_attributes || {};
return contact.value.custom_attributes || {};
});
const conversationId = computed(() => currentChat.value.id);
const toggleButtonText = computed(() =>
!showAllAttributes.value
? t('CUSTOM_ATTRIBUTES.SHOW_MORE')
: t('CUSTOM_ATTRIBUTES.SHOW_LESS')
);
const filteredAttributes = computed(() =>
attributes.value.map(attribute => {
// Check if the attribute key exists in customAttributes
const hasValue = Object.hasOwnProperty.call(
customAttributes.value,
attribute.attribute_key
);
const isCheckbox = attribute.attribute_display_type === 'checkbox';
const defaultValue = isCheckbox ? false : '';
return {
uiSettings,
updateUISettings,
...attribute,
// Set value from customAttributes if it exists, otherwise use default value
value: hasValue
? customAttributes.value[attribute.attribute_key]
: defaultValue,
};
},
data() {
return {
showAllAttributes: false,
};
},
computed: {
toggleButtonText() {
return !this.showAllAttributes
? this.$t('CUSTOM_ATTRIBUTES.SHOW_MORE')
: this.$t('CUSTOM_ATTRIBUTES.SHOW_LESS');
},
filteredAttributes() {
return this.attributes.map(attribute => {
// Check if the attribute key exists in customAttributes
const hasValue = Object.hasOwnProperty.call(
this.customAttributes,
attribute.attribute_key
);
})
);
const isCheckbox = attribute.attribute_display_type === 'checkbox';
const defaultValue = isCheckbox ? false : '';
const displayedAttributes = computed(() => {
// Show only the first 5 attributes or all depending on showAllAttributes
if (showAllAttributes.value || filteredAttributes.value.length <= 5) {
return filteredAttributes.value;
}
return filteredAttributes.value.slice(0, 5);
});
return {
...attribute,
// Set value from customAttributes if it exists, otherwise use default value
value: hasValue
? this.customAttributes[attribute.attribute_key]
: defaultValue,
};
});
},
displayedAttributes() {
// Show only the first 5 attributes or all depending on showAllAttributes
if (this.showAllAttributes || this.filteredAttributes.length <= 5) {
return this.filteredAttributes;
}
return this.filteredAttributes.slice(0, 5);
},
showMoreUISettingsKey() {
return `show_all_attributes_${this.attributeFrom}`;
},
},
mounted() {
this.initializeSettings();
},
methods: {
initializeSettings() {
this.showAllAttributes =
this.uiSettings[this.showMoreUISettingsKey] || false;
},
onClickToggle() {
this.showAllAttributes = !this.showAllAttributes;
this.updateUISettings({
[this.showMoreUISettingsKey]: this.showAllAttributes,
});
},
async onUpdate(key, value) {
const updatedAttributes = { ...this.customAttributes, [key]: value };
try {
if (this.attributeType === 'conversation_attribute') {
await this.$store.dispatch('updateCustomAttributes', {
conversationId: this.conversationId,
customAttributes: updatedAttributes,
});
} else {
this.$store.dispatch('contacts/update', {
id: this.contactId,
custom_attributes: updatedAttributes,
});
}
useAlert(this.$t('CUSTOM_ATTRIBUTES.FORM.UPDATE.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message ||
this.$t('CUSTOM_ATTRIBUTES.FORM.UPDATE.ERROR');
useAlert(errorMessage);
}
},
async onDelete(key) {
try {
const { [key]: remove, ...updatedAttributes } = this.customAttributes;
if (this.attributeType === 'conversation_attribute') {
await this.$store.dispatch('updateCustomAttributes', {
conversationId: this.conversationId,
customAttributes: updatedAttributes,
});
} else {
this.$store.dispatch('contacts/deleteCustomAttributes', {
id: this.contactId,
customAttributes: [key],
});
}
const showMoreUISettingsKey = computed(
() => `show_all_attributes_${props.attributeFrom}`
);
useAlert(this.$t('CUSTOM_ATTRIBUTES.FORM.DELETE.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message ||
this.$t('CUSTOM_ATTRIBUTES.FORM.DELETE.ERROR');
useAlert(errorMessage);
}
},
async onCopy(attributeValue) {
await copyTextToClipboard(attributeValue);
useAlert(this.$t('CUSTOM_ATTRIBUTES.COPY_SUCCESSFUL'));
},
},
const initializeSettings = () => {
showAllAttributes.value =
uiSettings.value[showMoreUISettingsKey.value] || false;
};
const onClickToggle = () => {
toggleShowAllAttributes();
updateUISettings({
[showMoreUISettingsKey.value]: showAllAttributes.value,
});
};
const onUpdate = async (key, value) => {
const updatedAttributes = { ...customAttributes.value, [key]: value };
try {
if (props.attributeType === 'conversation_attribute') {
await store.dispatch('updateCustomAttributes', {
conversationId: conversationId.value,
customAttributes: updatedAttributes,
});
} else {
store.dispatch('contacts/update', {
id: props.contactId,
custom_attributes: updatedAttributes,
});
}
useAlert(t('CUSTOM_ATTRIBUTES.FORM.UPDATE.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message || t('CUSTOM_ATTRIBUTES.FORM.UPDATE.ERROR');
useAlert(errorMessage);
}
};
const onDelete = async key => {
try {
const { [key]: remove, ...updatedAttributes } = customAttributes.value;
if (props.attributeType === 'conversation_attribute') {
await store.dispatch('updateCustomAttributes', {
conversationId: conversationId.value,
customAttributes: updatedAttributes,
});
} else {
store.dispatch('contacts/deleteCustomAttributes', {
id: props.contactId,
customAttributes: [key],
});
}
useAlert(t('CUSTOM_ATTRIBUTES.FORM.DELETE.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message || t('CUSTOM_ATTRIBUTES.FORM.DELETE.ERROR');
useAlert(errorMessage);
}
};
const onCopy = async attributeValue => {
await copyTextToClipboard(attributeValue);
useAlert(t('CUSTOM_ATTRIBUTES.COPY_SUCCESSFUL'));
};
onMounted(() => {
initializeSettings();
});
const evenClass = [
'[&>*:nth-child(odd)]:!bg-white [&>*:nth-child(even)]:!bg-slate-25',
'dark:[&>*:nth-child(odd)]:!bg-slate-900 dark:[&>*:nth-child(even)]:!bg-slate-800/50',
];
const oddClass = [
'[&>*:nth-child(odd)]:!bg-slate-25 [&>*:nth-child(even)]:!bg-white',
'dark:[&>*:nth-child(odd)]:!bg-slate-800/50 dark:[&>*:nth-child(even)]:!bg-slate-900',
];
const wrapperClass = computed(() => {
return props.startAt === 'even' ? evenClass : oddClass;
});
</script>
<!-- TODO: After migration to Vue 3, remove the top level div -->
<template>
<div class="custom-attributes--panel">
<div :class="wrapperClass">
<CustomAttribute
v-for="attribute in displayedAttributes"
:key="attribute.id"
@@ -160,8 +194,8 @@ export default {
show-actions
:attribute-regex="attribute.regex_pattern"
:regex-cue="attribute.regex_cue"
:class="attributeClass"
:contact-id="contactId"
class="border-b border-solid border-slate-50 dark:border-slate-700/50"
@update="onUpdate"
@delete="onDelete"
@copy="onCopy"
@@ -187,27 +221,3 @@ export default {
</div>
</div>
</template>
<style scoped lang="scss">
.custom-attributes--panel {
.conversation--attribute {
@apply border-slate-50 dark:border-slate-700/50 border-b border-solid;
}
&.odd {
.conversation--attribute {
&:nth-child(2n + 1) {
@apply bg-slate-25 dark:bg-slate-800/50;
}
}
}
&.even {
.conversation--attribute {
&:nth-child(2n) {
@apply bg-slate-25 dark:bg-slate-800/50;
}
}
}
}
</style>
@@ -1,11 +1,12 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Spinner from 'shared/components/Spinner.vue';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
export default {
components: {
@@ -13,27 +14,61 @@ export default {
LabelDropdown,
AddLabel,
},
mixins: [conversationLabelMixin, keyboardEventListenerMixins],
props: {
// conversationId prop is used in /conversation/labelMixin,
// remove this props when refactoring to composable if not needed
// eslint-disable-next-line vue/no-unused-properties
conversationId: {
type: Number,
required: true,
},
},
setup() {
const { isAdmin } = useAdmin();
const {
savedLabels,
activeLabels,
accountLabels,
addLabelToConversation,
removeLabelFromConversation,
} = useConversationLabels();
const conversationLabelBoxRef = ref(null);
const showSearchDropdownLabel = ref(false);
const toggleLabels = () => {
showSearchDropdownLabel.value = !showSearchDropdownLabel.value;
};
const closeDropdownLabel = () => {
showSearchDropdownLabel.value = false;
};
const keyboardEvents = {
KeyL: {
action: e => {
e.preventDefault();
toggleLabels();
},
},
Escape: {
action: () => {
if (showSearchDropdownLabel.value) {
toggleLabels();
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, conversationLabelBoxRef);
return {
isAdmin,
savedLabels,
activeLabels,
accountLabels,
addLabelToConversation,
removeLabelFromConversation,
conversationLabelBoxRef,
showSearchDropdownLabel,
closeDropdownLabel,
toggleLabels,
};
},
data() {
return {
selectedLabels: [],
showSearchDropdownLabel: false,
};
},
@@ -42,37 +77,11 @@ export default {
conversationUiFlags: 'conversationLabels/getUIFlags',
}),
},
methods: {
toggleLabels() {
this.showSearchDropdownLabel = !this.showSearchDropdownLabel;
},
closeDropdownLabel() {
this.showSearchDropdownLabel = false;
},
getKeyboardEvents() {
return {
KeyL: {
action: e => {
e.preventDefault();
this.toggleLabels();
},
},
Escape: {
action: () => {
if (this.showSearchDropdownLabel) {
this.toggleLabels();
}
},
allowOnFocusedInput: true,
},
};
},
},
};
</script>
<template>
<div class="sidebar-labels-wrap">
<div ref="conversationLabelBoxRef" class="sidebar-labels-wrap">
<div
v-if="!conversationUiFlags.isFetching"
class="contact-conversation--list"
@@ -1,53 +1,51 @@
<script>
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
<script setup>
import { ref, onMounted } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
export default {
name: 'ChatwootSearch',
mixins: [keyboardEventListenerMixins],
props: {
title: {
type: String,
default: 'Chatwoot',
defineProps({
title: {
type: String,
default: 'Chatwoot',
},
});
const emit = defineEmits(['search', 'close']);
const articleSearchHeaderRef = ref(null);
const searchInputRef = ref(null);
const searchQuery = ref('');
onMounted(() => {
searchInputRef.value.focus();
});
const onInput = e => {
emit('search', e.target.value);
};
const onClose = () => {
emit('close');
};
const keyboardEvents = {
Slash: {
action: e => {
e.preventDefault();
searchInputRef.value.focus();
},
},
data() {
return {
searchQuery: '',
isInputFocused: false,
};
},
mounted() {
this.$refs.searchInput.focus();
},
methods: {
onInput(e) {
this.$emit('search', e.target.value);
},
onClose() {
this.$emit('close');
},
onFocus() {
this.isInputFocused = true;
},
onBlur() {
this.isInputFocused = false;
},
getKeyboardEvents() {
return {
Slash: {
action: e => {
e.preventDefault();
this.$refs.searchInput.focus();
},
},
};
Escape: {
action: () => {
onClose();
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, articleSearchHeaderRef);
</script>
<template>
<div class="flex flex-col py-1">
<div ref="articleSearchHeaderRef" 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 }}
@@ -68,13 +66,11 @@ export default {
<fluent-icon icon="search" class="" size="18" />
</div>
<input
ref="searchInput"
ref="searchInputRef"
type="text"
:placeholder="$t('HELP_CENTER.ARTICLE_SEARCH.PLACEHOLDER')"
class="block w-full !h-9 ltr:!pl-8 rtl:!pr-8 dark:!bg-slate-700 !bg-slate-25 text-sm rounded-md leading-8 text-slate-700 shadow-sm ring-2 ring-transparent ring-slate-300 border border-solid border-slate-300 placeholder:text-slate-400 focus:border-woot-600 focus:ring-woot-200 !mb-0 focus:bg-slate-25 dark:focus:bg-slate-700 dark:focus:ring-woot-700"
:value="searchQuery"
@focus="onFocus"
@blur="onBlur"
@input="onInput"
/>
</div>
@@ -1,7 +1,6 @@
<script>
import { debounce } from '@chatwoot/utils';
import { useAlert } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import SearchHeader from './Header.vue';
import SearchResults from './SearchResults.vue';
@@ -17,7 +16,7 @@ export default {
SearchResults,
ArticleView,
},
mixins: [portalMixin, keyboardEventListenerMixins],
mixins: [portalMixin],
props: {
selectedPortalSlug: {
type: String,
@@ -114,16 +113,6 @@ export default {
useAlert(this.$t('HELP_CENTER.ARTICLE_SEARCH.SUCCESS_ARTICLE_INSERTED'));
this.onClose();
},
getKeyboardEvents() {
return {
Escape: {
action: () => {
this.onClose();
},
allowOnFocusedInput: true,
},
};
},
},
};
</script>
@@ -8,12 +8,14 @@ import InboxCard from './components/InboxCard.vue';
import InboxListHeader from './components/InboxListHeader.vue';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
export default {
components: {
InboxCard,
InboxListHeader,
IntersectionObserver,
CmdBarConversationSnooze,
},
setup() {
const { uiSettings } = useUISettings();
@@ -209,5 +211,6 @@ export default {
</div>
</div>
<router-view />
<CmdBarConversationSnooze />
</section>
</template>
@@ -1,6 +1,5 @@
<script>
import { mapGetters } from 'vuex';
import rtlMixin from 'shared/mixins/rtlMixin';
import NotificationPanelList from './NotificationPanelList.vue';
import { ACCOUNT_EVENTS } from '../../../../helper/AnalyticsHelper/events';
@@ -9,7 +8,6 @@ export default {
components: {
NotificationPanelList,
},
mixins: [rtlMixin],
data() {
return {
pageSize: 15,
@@ -21,9 +19,6 @@ export default {
records: 'notifications/getNotifications',
uiFlags: 'notifications/getUIFlags',
}),
notificationPanelFooterIconClass() {
return this.isRTLView ? '-mr-3' : '-ml-3';
},
totalUnreadNotifications() {
return this.meta.unreadCount;
},
@@ -201,7 +196,7 @@ export default {
<fluent-icon
icon="chevron-left"
size="16"
:class="notificationPanelFooterIconClass"
class="rtl:-mr-3 ltr:-ml-3"
/>
</woot-button>
<woot-button
@@ -236,7 +231,7 @@ export default {
<fluent-icon
icon="chevron-right"
size="16"
:class="notificationPanelFooterIconClass"
class="rtl:-mr-3 ltr:-ml-3"
/>
</woot-button>
</div>
@@ -4,21 +4,35 @@ defineProps({
type: Boolean,
default: false,
},
noRecordsFound: {
type: Boolean,
default: false,
},
loadingMessage: {
type: String,
default: '',
},
noRecordsMessage: {
type: String,
default: '',
},
});
</script>
<template>
<div class="flex flex-col w-full h-full gap-10 font-inter">
<slot name="header" />
<div>
<slot v-if="isLoading" name="loading">
<woot-loading-state :message="loadingMessage" />
</slot>
<slot v-else name="body" />
</div>
<slot v-if="isLoading" name="loading">
<woot-loading-state :message="loadingMessage" />
</slot>
<p
v-else-if="noRecordsFound"
class="flex-1 text-slate-700 dark:text-slate-100 flex items-center justify-center text-base"
>
{{ noRecordsMessage }}
</p>
<slot v-else name="body" />
<!-- Do not delete the slot below. It is required to render anything that is not defined in the above slots. -->
<slot />
</div>
</template>
@@ -4,19 +4,18 @@ import { required, minValue, maxValue } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import configMixin from 'shared/mixins/configMixin';
import accountMixin from '../../../../mixins/account';
import { useConfig } from 'dashboard/composables/useConfig';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import semver from 'semver';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
export default {
mixins: [accountMixin, configMixin],
setup() {
const { updateUISettings } = useUISettings();
const { enabledLanguages } = useConfig();
const v$ = useVuelidate();
return { updateUISettings, v$ };
return { updateUISettings, v$, enabledLanguages };
},
data() {
return {
@@ -1,230 +1,208 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { useAlert } from 'dashboard/composables';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import Thumbnail from '../../../../components/widgets/Thumbnail.vue';
import { computed, onMounted, ref } from 'vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import AddAgent from './AddAgent.vue';
import EditAgent from './EditAgent.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
export default {
components: {
AddAgent,
EditAgent,
Thumbnail,
},
mixins: [globalConfigMixin],
data() {
return {
loading: {},
showAddPopup: false,
showDeletePopup: false,
showEditPopup: false,
agentAPI: {
message: '',
},
currentAgent: {},
};
},
computed: {
...mapGetters({
agentList: 'agents/getAgents',
uiFlags: 'agents/getUIFlags',
currentUserId: 'getCurrentUserID',
globalConfig: 'globalConfig/get',
}),
deleteConfirmText() {
return `${this.$t('AGENT_MGMT.DELETE.CONFIRM.YES')} ${
this.currentAgent.name
}`;
},
deleteRejectText() {
return `${this.$t('AGENT_MGMT.DELETE.CONFIRM.NO')} ${
this.currentAgent.name
}`;
},
deleteMessage() {
return ` ${this.currentAgent.name}?`;
},
},
mounted() {
this.$store.dispatch('agents/get');
},
methods: {
showEditAction(agent) {
return this.currentUserId !== agent.id;
},
showDeleteAction(agent) {
if (this.currentUserId === agent.id) {
return false;
}
const getters = useStoreGetters();
const store = useStore();
const { t } = useI18n();
if (!agent.confirmed) {
return true;
}
const loading = ref({});
const showAddPopup = ref(false);
const showDeletePopup = ref(false);
const showEditPopup = ref(false);
const agentAPI = ref({ message: '' });
const currentAgent = ref({});
if (agent.role === 'administrator') {
return this.verifiedAdministrators().length !== 1;
}
return true;
},
verifiedAdministrators() {
return this.agentList.filter(
agent => agent.role === 'administrator' && agent.confirmed
);
},
// Edit Function
openAddPopup() {
this.showAddPopup = true;
},
hideAddPopup() {
this.showAddPopup = false;
},
const deleteConfirmText = computed(
() => `${t('AGENT_MGMT.DELETE.CONFIRM.YES')} ${currentAgent.value.name}`
);
const deleteRejectText = computed(() => {
return `${t('AGENT_MGMT.DELETE.CONFIRM.NO')} ${currentAgent.value.name}`;
});
const deleteMessage = computed(() => {
return ` ${currentAgent.value.name}?`;
});
// Edit Function
openEditPopup(agent) {
this.showEditPopup = true;
this.currentAgent = agent;
},
hideEditPopup() {
this.showEditPopup = false;
},
const agentList = computed(() => getters['agents/getAgents'].value);
const uiFlags = computed(() => getters['agents/getUIFlags'].value);
const currentUserId = computed(() => getters.getCurrentUserID.value);
// Delete Function
openDeletePopup(agent) {
this.showDeletePopup = true;
this.currentAgent = agent;
},
closeDeletePopup() {
this.showDeletePopup = false;
},
confirmDeletion() {
this.loading[this.currentAgent.id] = true;
this.closeDeletePopup();
this.deleteAgent(this.currentAgent.id);
},
async deleteAgent(id) {
try {
await this.$store.dispatch('agents/delete', id);
this.showAlertMessage(this.$t('AGENT_MGMT.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
this.showAlertMessage(this.$t('AGENT_MGMT.DELETE.API.ERROR_MESSAGE'));
}
},
// Show SnackBar
showAlertMessage(message) {
// Reset loading, current selected agent
this.loading[this.currentAgent.id] = false;
this.currentAgent = {};
// Show message
this.agentAPI.message = message;
useAlert(message);
},
},
onMounted(() => {
store.dispatch('agents/get');
});
const verifiedAdministrators = computed(() => {
return agentList.value.filter(
agent => agent.role === 'administrator' && agent.confirmed
);
});
const showEditAction = agent => {
return currentUserId.value !== agent.id;
};
const showDeleteAction = agent => {
if (currentUserId.value === agent.id) {
return false;
}
if (!agent.confirmed) {
return true;
}
if (agent.role === 'administrator') {
return verifiedAdministrators.value.length !== 1;
}
return true;
};
const showAlertMessage = message => {
loading.value[currentAgent.value.id] = false;
currentAgent.value = {};
agentAPI.value.message = message;
useAlert(message);
};
const openAddPopup = () => {
showAddPopup.value = true;
};
const hideAddPopup = () => {
showAddPopup.value = false;
};
const openEditPopup = agent => {
showEditPopup.value = true;
currentAgent.value = agent;
};
const hideEditPopup = () => {
showEditPopup.value = false;
};
const openDeletePopup = agent => {
showDeletePopup.value = true;
currentAgent.value = agent;
};
const closeDeletePopup = () => {
showDeletePopup.value = false;
};
const deleteAgent = async id => {
try {
await store.dispatch('agents/delete', id);
showAlertMessage(t('AGENT_MGMT.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
showAlertMessage(t('AGENT_MGMT.DELETE.API.ERROR_MESSAGE'));
}
};
const confirmDeletion = () => {
loading.value[currentAgent.value.id] = true;
closeDeletePopup();
deleteAgent(currentAgent.value.id);
};
</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('AGENT_MGMT.HEADER_BTN_TXT') }}
</woot-button>
<!-- List Agents -->
<div class="flex flex-row gap-4">
<div class="w-full lg:w-3/5">
<woot-loading-state
v-if="uiFlags.isFetching"
:message="$t('AGENT_MGMT.LOADING')"
/>
<div v-else>
<p v-if="!agentList.length">
{{ $t('AGENT_MGMT.LIST.404') }}
</p>
<table v-else class="woot-table">
<tbody>
<tr v-for="(agent, index) in agentList" :key="agent.email">
<!-- Gravtar Image -->
<td>
<Thumbnail
:src="agent.thumbnail"
:username="agent.name"
size="40px"
:status="agent.availability_status"
/>
</td>
<!-- Agent Name + Email -->
<td>
<span class="agent-name">
<SettingsLayout
:is-loading="uiFlags.isFetching"
:loading-message="$t('AGENT_MGMT.LOADING')"
:no-records-found="!agentList.length"
:no-records-message="$t('AGENT_MGMT.LIST.404')"
>
<template #header>
<BaseSettingsHeader
:title="$t('AGENT_MGMT.HEADER')"
:description="$t('AGENT_MGMT.DESCRIPTION')"
:link-text="$t('AGENT_MGMT.LEARN_MORE')"
feature-name="agents"
>
<template #actions>
<woot-button
class="button nice rounded-md"
icon="add-circle"
@click="openAddPopup"
>
{{ $t('AGENT_MGMT.HEADER_BTN_TXT') }}
</woot-button>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table class="divide-y divide-slate-75 dark:divide-slate-700">
<tbody
class="divide-y divide-slate-50 dark:divide-slate-800 text-slate-700 dark:text-slate-300"
>
<tr v-for="(agent, index) in agentList" :key="agent.email">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex items-center flex-row gap-4">
<Thumbnail
:src="agent.thumbnail"
:username="agent.name"
size="40px"
:status="agent.availability_status"
/>
<div>
<span class="block font-medium capitalize">
{{ agent.name }}
</span>
<span>{{ agent.email }}</span>
</td>
<!-- Agent Role + Verification Status -->
<td>
<span class="agent-name">
{{
$t(`AGENT_MGMT.AGENT_TYPES.${agent.role.toUpperCase()}`)
}}
</span>
<span v-if="agent.confirmed">
{{ $t('AGENT_MGMT.LIST.VERIFIED') }}
</span>
<span v-if="!agent.confirmed">
{{ $t('AGENT_MGMT.LIST.VERIFICATION_PENDING') }}
</span>
</td>
<!-- Actions -->
<td>
<div class="button-wrapper">
<woot-button
v-if="showEditAction(agent)"
v-tooltip.top="$t('AGENT_MGMT.EDIT.BUTTON_TEXT')"
variant="smooth"
size="tiny"
color-scheme="secondary"
icon="edit"
class-names="grey-btn"
@click="openEditPopup(agent)"
/>
<woot-button
v-if="showDeleteAction(agent)"
v-tooltip.top="$t('AGENT_MGMT.DELETE.BUTTON_TEXT')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
class-names="grey-btn"
:is-loading="loading[agent.id]"
@click="openDeletePopup(agent, index)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="hidden w-1/3 lg:block">
<span
v-dompurify-html="
useInstallationName(
$t('AGENT_MGMT.SIDEBAR_TXT'),
globalConfig.installationName
)
"
/>
</div>
</div>
<!-- Add Agent -->
</div>
</div>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<span class="block font-medium capitalize">
{{ $t(`AGENT_MGMT.AGENT_TYPES.${agent.role.toUpperCase()}`) }}
</span>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<span v-if="agent.confirmed">
{{ $t('AGENT_MGMT.LIST.VERIFIED') }}
</span>
<span v-if="!agent.confirmed">
{{ $t('AGENT_MGMT.LIST.VERIFICATION_PENDING') }}
</span>
</td>
<td class="py-4">
<div class="flex justify-end gap-1">
<woot-button
v-if="showEditAction(agent)"
v-tooltip.top="$t('AGENT_MGMT.EDIT.BUTTON_TEXT')"
variant="smooth"
size="tiny"
color-scheme="secondary"
icon="edit"
class-names="grey-btn"
@click="openEditPopup(agent)"
/>
<woot-button
v-if="showDeleteAction(agent)"
v-tooltip.top="$t('AGENT_MGMT.DELETE.BUTTON_TEXT')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
class-names="grey-btn"
:is-loading="loading[agent.id]"
@click="openDeletePopup(agent, index)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</template>
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
<AddAgent :on-close="hideAddPopup" />
</woot-modal>
<!-- Edit Agent -->
<woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup">
<EditAgent
v-if="showEditPopup"
@@ -236,7 +214,7 @@ export default {
:on-close="hideEditPopup"
/>
</woot-modal>
<!-- Delete Agent -->
<woot-delete-modal
:show.sync="showDeletePopup"
:on-close="closeDeletePopup"
@@ -247,5 +225,5 @@ export default {
:confirm-text="deleteConfirmText"
:reject-text="deleteRejectText"
/>
</div>
</SettingsLayout>
</template>
@@ -1,12 +1,12 @@
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const SettingsWrapper = () => import('../SettingsWrapper.vue');
const AgentHome = () => import('./Index.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/agents'),
component: SettingsContent,
component: SettingsWrapper,
props: {
headerTitle: 'AGENT_MGMT.HEADER',
icon: 'people',
@@ -2,11 +2,10 @@
import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables';
import { required, minLength } from '@vuelidate/validators';
import { getRegexp } from 'shared/helpers/Validators';
import { ATTRIBUTE_TYPES } from './constants';
import customAttributeMixin from '../../../../mixins/customAttributeMixin';
export default {
components: {},
mixins: [customAttributeMixin],
props: {
selectedAttribute: {
type: Object,
@@ -116,7 +115,7 @@ export default {
},
setFormValues() {
const regexPattern = this.selectedAttribute.regex_pattern
? this.getRegexp(this.selectedAttribute.regex_pattern).source
? getRegexp(this.selectedAttribute.regex_pattern).source
: null;
this.displayName = this.selectedAttribute.attribute_display_name;
this.description = this.selectedAttribute.attribute_description;
@@ -2,12 +2,19 @@
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { mapGetters } from 'vuex';
import accountMixin from '../../../../mixins/account';
import { useAccount } from 'dashboard/composables/useAccount';
import BillingItem from './components/BillingItem.vue';
// sdds
export default {
components: { BillingItem },
mixins: [accountMixin, messageFormatterMixin],
mixins: [messageFormatterMixin],
setup() {
const { accountId } = useAccount();
return {
accountId,
};
},
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
@@ -1,238 +1,222 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { useAlert } from 'dashboard/composables';
import AddCanned from './AddCanned.vue';
import EditCanned from './EditCanned.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
export default {
components: {
AddCanned,
EditCanned,
},
data() {
return {
loading: {},
showAddPopup: false,
showEditPopup: false,
showDeleteConfirmationPopup: false,
selectedResponse: {},
cannedResponseAPI: {
message: '',
},
sortOrder: 'asc',
};
},
computed: {
...mapGetters({
records: 'getCannedResponses',
uiFlags: 'getUIFlags',
}),
// Delete Modal
deleteConfirmText() {
return `${this.$t('CANNED_MGMT.DELETE.CONFIRM.YES')} ${
this.selectedResponse.short_code
}`;
},
deleteRejectText() {
return `${this.$t('CANNED_MGMT.DELETE.CONFIRM.NO')} ${
this.selectedResponse.short_code
}`;
},
deleteMessage() {
return ` ${this.selectedResponse.short_code}?`;
},
},
mounted() {
// Fetch API Call
this.$store.dispatch('getCannedResponse').then(() => {
this.toggleSort();
});
},
methods: {
toggleSort() {
this.records.sort((a, b) => {
if (this.sortOrder === 'asc') {
return a.short_code.localeCompare(b.short_code);
}
return b.short_code.localeCompare(a.short_code);
});
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
},
showAlertMessage(message) {
// Reset loading, current selected agent
this.loading[this.selectedResponse.id] = false;
this.selectedResponse = {};
// Show message
this.cannedResponseAPI.message = message;
useAlert(message);
},
// Edit Function
openAddPopup() {
this.showAddPopup = true;
},
hideAddPopup() {
this.showAddPopup = false;
},
const getters = useStoreGetters();
const store = useStore();
const { t } = useI18n();
// Edit Modal Functions
openEditPopup(response) {
this.showEditPopup = true;
this.selectedResponse = response;
},
hideEditPopup() {
this.showEditPopup = false;
},
const showAddPopup = ref(false);
const loading = ref({});
const showEditPopup = ref(false);
const showDeleteConfirmationPopup = ref(false);
const activeResponse = ref({});
const cannedResponseAPI = ref({ message: '' });
// Delete Modal Functions
openDeletePopup(response) {
this.showDeleteConfirmationPopup = true;
this.selectedResponse = response;
},
closeDeletePopup() {
this.showDeleteConfirmationPopup = false;
},
// Set loading and call Delete API
confirmDeletion() {
this.loading[this.selectedResponse.id] = true;
this.closeDeletePopup();
this.deleteCannedResponse(this.selectedResponse.id);
},
deleteCannedResponse(id) {
this.$store
.dispatch('deleteCannedResponse', id)
.then(() => {
this.showAlertMessage(
this.$t('CANNED_MGMT.DELETE.API.SUCCESS_MESSAGE')
);
})
.catch(error => {
const errorMessage =
error?.message || this.$t('CANNED_MGMT.DELETE.API.ERROR_MESSAGE');
this.showAlertMessage(errorMessage);
});
},
},
const sortOrder = ref('asc');
const records = computed(() =>
getters.getSortedCannedResponses.value(sortOrder.value)
);
const uiFlags = computed(() => getters.getUIFlags.value);
const deleteConfirmText = computed(
() =>
`${t('CANNED_MGMT.DELETE.CONFIRM.YES')} ${activeResponse.value.short_code}`
);
const deleteRejectText = computed(
() =>
`${t('CANNED_MGMT.DELETE.CONFIRM.NO')} ${activeResponse.value.short_code}`
);
const deleteMessage = computed(() => {
return ` ${activeResponse.value.short_code} ? `;
});
const toggleSort = () => {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
};
const fetchCannedResponses = async () => {
try {
await store.dispatch('getCannedResponse');
} catch (error) {
// Ignore Error
}
};
onMounted(() => {
fetchCannedResponses();
});
const showAlertMessage = message => {
loading[activeResponse.value.id] = false;
activeResponse.value = {};
cannedResponseAPI.value.message = message;
useAlert(message);
};
const openAddPopup = () => {
showAddPopup.value = true;
};
const hideAddPopup = () => {
showAddPopup.value = false;
};
const openEditPopup = response => {
showEditPopup.value = true;
activeResponse.value = response;
};
const hideEditPopup = () => {
showEditPopup.value = false;
};
const openDeletePopup = response => {
showDeleteConfirmationPopup.value = true;
activeResponse.value = response;
};
const closeDeletePopup = () => {
showDeleteConfirmationPopup.value = false;
};
const deleteCannedResponse = async id => {
try {
await store.dispatch('deleteCannedResponse', id);
showAlertMessage(t('CANNED_MGMT.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.message || t('CANNED_MGMT.DELETE.API.ERROR_MESSAGE');
showAlertMessage(errorMessage);
}
};
const confirmDeletion = () => {
loading[activeResponse.value.id] = true;
closeDeletePopup();
deleteCannedResponse(activeResponse.value.id);
};
</script>
<template>
<div class="flex-1 overflow-auto">
<woot-button
color-scheme="success"
class-names="button--fixed-top"
icon="add-circle"
@click="openAddPopup()"
<BaseSettingsHeader
:title="$t('CANNED_MGMT.HEADER')"
:description="$t('CANNED_MGMT.DESCRIPTION')"
:link-text="$t('CANNED_MGMT.LEARN_MORE')"
feature-name="canned_responses"
>
{{ $t('CANNED_MGMT.HEADER_BTN_TXT') }}
</woot-button>
<!-- List Canned Response -->
<div class="flex flex-row gap-4 p-8">
<div class="w-full xl:w-3/5">
<p
v-if="!uiFlags.fetchingList && !records.length"
class="flex flex-col items-center justify-center h-full"
<template #actions>
<woot-button
class="button nice rounded-md"
icon="add-circle"
@click="openAddPopup"
>
{{ $t('CANNED_MGMT.LIST.404') }}
</p>
<woot-loading-state
v-if="uiFlags.fetchingList"
:message="$t('CANNED_MGMT.LOADING')"
/>
{{ $t('CANNED_MGMT.HEADER_BTN_TXT') }}
</woot-button>
</template>
</BaseSettingsHeader>
<table
v-if="!uiFlags.fetchingList && records.length"
class="woot-table"
>
<thead>
<!-- Header -->
<th
v-for="thHeader in $t('CANNED_MGMT.LIST.TABLE_HEADER')"
:key="thHeader"
class="last:text-right first:m-0 first:p-0"
<div class="mt-6 flex-1">
<woot-loading-state
v-if="uiFlags.fetchingList"
:message="$t('CANNED_MGMT.LOADING')"
/>
<p
v-else-if="!records.length"
class="flex flex-col items-center justify-center h-full text-base text-slate-600 dark:text-slate-300 py-8"
>
{{ $t('CANNED_MGMT.LIST.404') }}
</p>
<table
v-else
class="min-w-full overflow-x-auto divide-y divide-slate-75 dark:divide-slate-700"
>
<thead>
<th
v-for="thHeader in $t('CANNED_MGMT.LIST.TABLE_HEADER')"
:key="thHeader"
class="py-4 pr-4 text-left font-semibold text-slate-700 dark:text-slate-300"
>
<span v-if="thHeader !== $t('CANNED_MGMT.LIST.TABLE_HEADER[0]')">
{{ thHeader }}
</span>
<button
v-if="thHeader === $t('CANNED_MGMT.LIST.TABLE_HEADER[0]')"
class="flex items-center p-0 cursor-pointer"
@click="toggleSort"
>
<p v-if="thHeader !== $t('CANNED_MGMT.LIST.TABLE_HEADER[0]')">
<span class="mb-0">
{{ thHeader }}
</p>
<button
v-if="thHeader === $t('CANNED_MGMT.LIST.TABLE_HEADER[0]')"
class="flex items-center p-0 cursor-pointer"
@click="toggleSort"
>
<p class="uppercase">
{{ thHeader }}
</p>
<fluent-icon
class="mb-2 ml-2"
:icon="sortOrder === 'asc' ? 'chevron-up' : 'chevron-down'"
/>
</button>
</th>
</thead>
<tbody>
<tr
v-for="(cannedItem, index) in records"
:key="cannedItem.short_code"
</span>
<fluent-icon
class="ml-2"
:icon="sortOrder === 'desc' ? 'chevron-up' : 'chevron-down'"
/>
</button>
</th>
</thead>
<tbody
class="divide-y divide-slate-50 dark:divide-slate-800 text-slate-700 dark:text-slate-300"
>
<tr
v-for="(cannedItem, index) in records"
:key="cannedItem.short_code"
>
<td
class="py-4 pr-4 truncate max-w-xs font-medium"
:title="cannedItem.short_code"
>
<!-- Short Code -->
<td
class="w-[8.75rem] truncate max-w-[8.75rem]"
:title="cannedItem.short_code"
>
{{ cannedItem.short_code }}
</td>
<!-- Content -->
<td class="break-all whitespace-normal">
{{ cannedItem.content }}
</td>
<!-- Action Buttons -->
<td class="flex justify-end gap-1 min-w-[12.5rem]">
<woot-button
v-tooltip.top="$t('CANNED_MGMT.EDIT.BUTTON_TEXT')"
variant="smooth"
size="tiny"
color-scheme="secondary"
icon="edit"
@click="openEditPopup(cannedItem)"
/>
<woot-button
v-tooltip.top="$t('CANNED_MGMT.DELETE.BUTTON_TEXT')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
class-names="grey-btn"
:is-loading="loading[cannedItem.id]"
@click="openDeletePopup(cannedItem, index)"
/>
</td>
</tr>
</tbody>
</table>
</div>
<div class="hidden w-1/3 xl:block">
<span v-dompurify-html="$t('CANNED_MGMT.SIDEBAR_TXT')" />
</div>
{{ cannedItem.short_code }}
</td>
<td class="py-4 pr-4 md:break-all whitespace-normal">
{{ cannedItem.content }}
</td>
<td class="py-4 flex justify-end gap-1">
<woot-button
v-tooltip.top="$t('CANNED_MGMT.EDIT.BUTTON_TEXT')"
variant="smooth"
size="tiny"
color-scheme="secondary"
icon="edit"
@click="openEditPopup(cannedItem)"
/>
<woot-button
v-tooltip.top="$t('CANNED_MGMT.DELETE.BUTTON_TEXT')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
class-names="grey-btn"
:is-loading="loading[cannedItem.id]"
@click="openDeletePopup(cannedItem, index)"
/>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Add Agent -->
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
<AddCanned :on-close="hideAddPopup" />
</woot-modal>
<!-- Edit Canned Response -->
<woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup">
<EditCanned
v-if="showEditPopup"
:id="selectedResponse.id"
:edshort-code="selectedResponse.short_code"
:edcontent="selectedResponse.content"
:id="activeResponse.id"
:edshort-code="activeResponse.short_code"
:edcontent="activeResponse.content"
:on-close="hideEditPopup"
/>
</woot-modal>
<!-- Delete Canned Response -->
<woot-delete-modal
:show.sync="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
@@ -1,18 +1,13 @@
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const SettingsWrapper = () => import('../SettingsWrapper.vue');
const CannedHome = () => import('./Index.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/canned-response'),
component: SettingsContent,
props: {
headerTitle: 'CANNED_MGMT.HEADER',
icon: 'chat-multiple',
showNewButton: false,
},
component: SettingsWrapper,
children: [
{
path: '',
@@ -1,12 +1,10 @@
<script>
import configMixin from 'shared/mixins/configMixin';
import EmptyState from '../../../../components/widgets/EmptyState.vue';
export default {
components: {
EmptyState,
},
mixins: [configMixin],
computed: {
currentInbox() {
return this.$store.getters['inboxes/getInbox'](
@@ -94,11 +92,11 @@ export default {
/>
</div>
<div v-if="isWhatsAppCloudInbox" class="w-[50%] max-w-[50%] ml-[25%]">
<p class="text-slate-700 dark:text-slate-200 font-medium mt-8">
<p class="mt-8 font-medium text-slate-700 dark:text-slate-200">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.WEBHOOK_URL') }}
</p>
<woot-code lang="html" :script="currentInbox.callback_webhook_url" />
<p class="text-slate-700 dark:text-slate-200 font-medium mt-8">
<p class="mt-8 font-medium text-slate-700 dark:text-slate-200">
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.WEBHOOK_VERIFICATION_TOKEN'
@@ -132,7 +130,7 @@ export default {
</div>
<div class="flex justify-center gap-2 mt-4">
<router-link
class="button hollow primary rounded"
class="rounded button hollow primary"
:to="{
name: 'settings_inbox_show',
params: { inboxId: $route.params.inbox_id },
@@ -141,7 +139,7 @@ export default {
{{ $t('INBOX_MGMT.FINISH.MORE_SETTINGS') }}
</router-link>
<router-link
class="button success rounded"
class="rounded button success"
:to="{
name: 'inbox_dashboard',
params: { inboxId: $route.params.inbox_id },
@@ -2,19 +2,21 @@
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useAccount } from 'dashboard/composables/useAccount';
import Settings from './Settings.vue';
import accountMixin from '../../../../mixins/account';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
export default {
components: {
Settings,
},
mixins: [accountMixin, globalConfigMixin],
mixins: [globalConfigMixin],
setup() {
const { isAdmin } = useAdmin();
const { accountScopedUrl } = useAccount();
return {
isAdmin,
accountScopedUrl,
};
},
data() {
@@ -102,7 +104,7 @@ export default {
{{ $t('INBOX_MGMT.LIST.404') }}
<router-link
v-if="isAdmin"
:to="addAccountScoping('settings/inboxes/new')"
:to="accountScopedUrl('settings/inboxes/new')"
>
{{ $t('SETTINGS.INBOXES.NEW_INBOX') }}
</router-link>
@@ -164,7 +166,7 @@ export default {
<td>
<div class="button-wrapper">
<router-link
:to="addAccountScoping(`settings/inboxes/${item.id}`)"
:to="accountScopedUrl(`settings/inboxes/${item.id}`)"
>
<woot-button
v-if="isAdmin"
@@ -1,7 +1,6 @@
<script>
import { mapGetters } from 'vuex';
import { shouldBeUrl } from 'shared/helpers/Validators';
import configMixin from 'shared/mixins/configMixin';
import { useAlert } from 'dashboard/composables';
import { useVuelidate } from '@vuelidate/core';
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
@@ -34,7 +33,7 @@ export default {
SenderNameExamplePreview,
MicrosoftReauthorize,
},
mixins: [configMixin, inboxMixin],
mixins: [inboxMixin],
setup() {
return { v$: useVuelidate() };
},
@@ -3,6 +3,7 @@
/* global FB */
import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import { required } from '@vuelidate/validators';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import { mapGetters } from 'vuex';
@@ -10,7 +11,6 @@ import ChannelApi from '../../../../../api/channels';
import PageHeader from '../../SettingsSubPageHeader.vue';
import router from '../../../../index';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import accountMixin from '../../../../../mixins/account';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import * as Sentry from '@sentry/browser';
@@ -20,9 +20,13 @@ export default {
LoadingState,
PageHeader,
},
mixins: [globalConfigMixin, accountMixin],
mixins: [globalConfigMixin],
setup() {
return { v$: useVuelidate() };
const { accountId } = useAccount();
return {
accountId,
v$: useVuelidate(),
};
},
data() {
return {
@@ -3,14 +3,13 @@ import { mapGetters } from 'vuex';
import { useVuelidate } from '@vuelidate/core';
import { minValue } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import configMixin from 'shared/mixins/configMixin';
import { useConfig } from 'dashboard/composables/useConfig';
import SettingsSection from '../../../../../components/SettingsSection.vue';
export default {
components: {
SettingsSection,
},
mixins: [configMixin],
props: {
inbox: {
type: Object,
@@ -18,7 +17,9 @@ export default {
},
},
setup() {
return { v$: useVuelidate() };
const { isEnterprise } = useConfig();
return { v$: useVuelidate(), isEnterprise };
},
data() {
return {
@@ -1,138 +1,137 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { useAlert } from 'dashboard/composables';
import { computed, onBeforeMount, ref } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import AddLabel from './AddLabel.vue';
import EditLabel from './EditLabel.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
export default {
components: {
AddLabel,
EditLabel,
},
data() {
return {
loading: {},
showAddPopup: false,
showEditPopup: false,
showDeleteConfirmationPopup: false,
selectedResponse: {},
};
},
computed: {
...mapGetters({
records: 'labels/getLabels',
uiFlags: 'labels/getUIFlags',
}),
// Delete Modal
deleteConfirmText() {
return this.$t('LABEL_MGMT.DELETE.CONFIRM.YES');
},
deleteRejectText() {
return this.$t('LABEL_MGMT.DELETE.CONFIRM.NO');
},
deleteMessage() {
return ` ${this.selectedResponse.title}?`;
},
},
mounted() {
this.$store.dispatch('labels/get');
},
methods: {
openAddPopup() {
this.showAddPopup = true;
},
hideAddPopup() {
this.showAddPopup = false;
},
const getters = useStoreGetters();
const store = useStore();
const { t } = useI18n();
openEditPopup(response) {
this.showEditPopup = true;
this.selectedResponse = response;
},
hideEditPopup() {
this.showEditPopup = false;
},
const loading = ref({});
const showAddPopup = ref(false);
const showEditPopup = ref(false);
const showDeleteConfirmationPopup = ref(false);
const selectedLabel = ref({});
openDeletePopup(response) {
this.showDeleteConfirmationPopup = true;
this.selectedResponse = response;
},
closeDeletePopup() {
this.showDeleteConfirmationPopup = false;
},
const records = computed(() => getters['labels/getLabels'].value);
const uiFlags = computed(() => getters['labels/getUIFlags'].value);
confirmDeletion() {
this.loading[this.selectedResponse.id] = true;
this.closeDeletePopup();
this.deleteLabel(this.selectedResponse.id);
},
deleteLabel(id) {
this.$store
.dispatch('labels/delete', id)
.then(() => {
useAlert(this.$t('LABEL_MGMT.DELETE.API.SUCCESS_MESSAGE'));
})
.catch(() => {
useAlert(this.$t('LABEL_MGMT.DELETE.API.ERROR_MESSAGE'));
})
.finally(() => {
this.loading[this.selectedResponse.id] = false;
});
},
},
const deleteMessage = computed(() => ` ${selectedLabel.value.title}?`);
const openAddPopup = () => {
showAddPopup.value = true;
};
const hideAddPopup = () => {
showAddPopup.value = false;
};
const openEditPopup = response => {
showEditPopup.value = true;
selectedLabel.value = response;
};
const hideEditPopup = () => {
showEditPopup.value = false;
};
const openDeletePopup = response => {
showDeleteConfirmationPopup.value = true;
selectedLabel.value = response;
};
const closeDeletePopup = () => {
showDeleteConfirmationPopup.value = false;
};
const deleteLabel = async id => {
try {
await store.dispatch('labels/delete', id);
useAlert(t('LABEL_MGMT.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.message || t('LABEL_MGMT.DELETE.API.ERROR_MESSAGE');
useAlert(errorMessage);
} finally {
loading.value[selectedLabel.value.id] = false;
}
};
const confirmDeletion = () => {
loading.value[selectedLabel.value.id] = true;
closeDeletePopup();
deleteLabel(selectedLabel.value.id);
};
onBeforeMount(() => {
store.dispatch('labels/get');
});
</script>
<template>
<div class="flex-1 overflow-auto">
<woot-button
color-scheme="success"
class-names="button--fixed-top"
icon="add-circle"
@click="openAddPopup"
>
{{ $t('LABEL_MGMT.HEADER_BTN_TXT') }}
</woot-button>
<div class="flex flex-row gap-4 p-8">
<div class="w-full xl: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('LABEL_MGMT.LOADING')"
:no-records-found="!records.length"
:no-records-message="$t('LABEL_MGMT.LIST.404')"
>
<template #header>
<BaseSettingsHeader
:title="$t('LABEL_MGMT.HEADER')"
:description="$t('LABEL_MGMT.DESCRIPTION')"
:link-text="$t('LABEL_MGMT.LEARN_MORE')"
feature-name="labels"
>
<template #actions>
<woot-button
class="button nice rounded-md"
icon="add-circle"
@click="openAddPopup"
>
{{ $t('LABEL_MGMT.HEADER_BTN_TXT') }}
</woot-button>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table
class="min-w-full overflow-x-auto divide-y divide-slate-75 dark:divide-slate-700"
>
<thead>
<th
v-for="thHeader in $t('LABEL_MGMT.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-25 dark:divide-slate-800 flex-1 text-slate-700 dark:text-slate-100"
>
{{ $t('LABEL_MGMT.LIST.404') }}
</p>
<woot-loading-state
v-if="uiFlags.isFetching"
:message="$t('LABEL_MGMT.LOADING')"
/>
<table v-if="!uiFlags.isFetching && records.length" class="woot-table">
<thead>
<th
v-for="thHeader in $t('LABEL_MGMT.LIST.TABLE_HEADER')"
:key="thHeader"
>
{{ thHeader }}
</th>
</thead>
<tbody>
<tr v-for="(label, index) in records" :key="label.title">
<td class="label-title">
<span class="overflow-hidden whitespace-nowrap text-ellipsis">{{
label.title
}}</span>
</td>
<td>{{ label.description }}</td>
<td>
<div class="label-color--container">
<span
class="label-color--display"
:style="{ backgroundColor: label.color }"
/>
{{ label.color }}
</div>
</td>
<td class="button-wrapper">
<tr v-for="(label, index) in records" :key="label.title">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<span
class="font-medium break-words text-slate-700 dark:text-slate-100 mb-1"
>
{{ label.title }}
</span>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">{{ label.description }}</td>
<td class="leading-6 py-4 ltr:pr-4 rtl:pl-4">
<div class="flex items-center">
<span
class="rounded h-4 w-4 mr-1 rtl:mr-0 rtl:ml-1 border border-solid border-slate-50 dark:border-slate-700"
:style="{ backgroundColor: label.color }"
/>
{{ label.color }}
</div>
</td>
<td class="py-4 min-w-xs">
<div class="flex gap-1">
<woot-button
v-tooltip.top="$t('LABEL_MGMT.FORM.EDIT')"
variant="smooth"
@@ -153,22 +152,19 @@ export default {
:is-loading="loading[label.id]"
@click="openDeletePopup(label, index)"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</template>
<div class="hidden w-1/3 xl:block">
<span v-dompurify-html="$t('LABEL_MGMT.SIDEBAR_TXT')" />
</div>
</div>
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
<AddLabel @close="hideAddPopup" />
</woot-modal>
<woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup">
<EditLabel :selected-response="selectedResponse" @close="hideEditPopup" />
<EditLabel :selected-response="selectedLabel" @close="hideEditPopup" />
</woot-modal>
<woot-delete-modal
@@ -178,26 +174,8 @@ export default {
:title="$t('LABEL_MGMT.DELETE.CONFIRM.TITLE')"
:message="$t('LABEL_MGMT.DELETE.CONFIRM.MESSAGE')"
:message-value="deleteMessage"
:confirm-text="deleteConfirmText"
:reject-text="deleteRejectText"
:confirm-text="$t('LABEL_MGMT.DELETE.CONFIRM.YES')"
:reject-text="$t('LABEL_MGMT.DELETE.CONFIRM.NO')"
/>
</div>
</SettingsLayout>
</template>
<style scoped lang="scss">
@import '~dashboard/assets/scss/variables';
.label-color--container {
@apply flex items-center;
}
.label-color--display {
@apply rounded h-4 w-4 mr-1 rtl:mr-0 rtl:ml-1 border border-solid border-slate-50 dark:border-slate-700;
}
.label-title {
span {
@apply w-60 inline-block;
}
}
</style>
@@ -1,18 +1,13 @@
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const SettingsWrapper = () => import('../SettingsWrapper.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/labels'),
component: SettingsContent,
props: {
headerTitle: 'LABEL_MGMT.HEADER',
icon: 'tag',
showNewButton: false,
},
component: SettingsWrapper,
children: [
{
path: '',
@@ -1,13 +1,18 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import accountMixin from 'dashboard/mixins/account.js';
import { useAccount } from 'dashboard/composables/useAccount';
import MacrosTableRow from './MacrosTableRow.vue';
export default {
components: {
MacrosTableRow,
},
mixins: [accountMixin],
setup() {
const { accountScopedUrl } = useAccount();
return {
accountScopedUrl,
};
},
data() {
return {
showDeleteConfirmationPopup: false,
@@ -56,7 +61,7 @@ export default {
<template>
<div class="flex-1 overflow-auto">
<router-link
:to="addAccountScoping('settings/macros/new')"
: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" />
@@ -67,7 +72,7 @@ export default {
<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 h-full items-center flex-col justify-center">
<p class="flex flex-col items-center justify-center h-full">
{{ $t('MACROS.LIST.404') }}
</p>
</div>
@@ -94,7 +99,7 @@ export default {
</tbody>
</table>
</div>
<div class="hidden lg:block w-1/3">
<div class="hidden w-1/3 lg:block">
<span v-dompurify-html="$t('MACROS.SIDEBAR_TXT')" />
</div>
</div>
@@ -1,126 +1,123 @@
<script>
<script setup>
import { ref, computed, watch, provide } from 'vue';
import { useRoute, useRouter } from 'dashboard/composables/route';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import MacroForm from './MacroForm.vue';
import { MACRO_ACTION_TYPES } from './constants';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import actionQueryGenerator from 'dashboard/helper/actionQueryGenerator.js';
import macrosMixin from 'dashboard/mixins/macrosMixin';
import { useMacros } from 'dashboard/composables/useMacros';
export default {
components: {
MacroForm,
},
mixins: [macrosMixin],
provide() {
return {
macroActionTypes: this.macroActionTypes,
};
},
data() {
return {
macro: null,
mode: 'CREATE',
macroActionTypes: MACRO_ACTION_TYPES,
};
},
computed: {
...mapGetters({
uiFlags: 'macros/getUIFlags',
}),
macroId() {
return this.$route.params.macroId;
},
},
watch: {
$route: {
handler() {
this.fetchDropdownData();
if (this.$route.params.macroId) {
this.fetchMacro();
} else {
this.initNewMacro();
}
},
immediate: true,
},
},
methods: {
fetchDropdownData() {
this.$store.dispatch('agents/get');
this.$store.dispatch('teams/get');
this.$store.dispatch('labels/get');
},
fetchMacro() {
this.mode = 'EDIT';
this.manifestMacro();
},
async manifestMacro() {
await this.$store.dispatch('macros/getSingleMacro', this.macroId);
const singleMacro = this.$store.getters['macros/getMacro'](this.macroId);
this.macro = this.formatMacro(singleMacro);
},
formatMacro(macro) {
const formattedActions = macro.actions.map(action => {
let actionParams = [];
if (action.action_params.length) {
const inputType = this.macroActionTypes.find(
item => item.key === action.action_name
).inputType;
if (inputType === 'multi_select' || inputType === 'search_select') {
actionParams = [
...this.getDropdownValues(action.action_name, this.$store),
].filter(item => [...action.action_params].includes(item.id));
} else if (inputType === 'team_message') {
actionParams = {
team_ids: [
...this.getDropdownValues(action.action_name, this.$store),
].filter(item =>
[...action.action_params[0].team_ids].includes(item.id)
),
message: action.action_params[0].message,
};
} else actionParams = [...action.action_params];
}
return {
...action,
action_params: actionParams,
const store = useStore();
const getters = useStoreGetters();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { getMacroDropdownValues } = useMacros();
const macro = ref(null);
const mode = ref('CREATE');
const macroActionTypes = MACRO_ACTION_TYPES;
provide('macroActionTypes', macroActionTypes);
const uiFlags = computed(() => getters['macros/getUIFlags'].value);
const macroId = computed(() => route.params.macroId);
const fetchDropdownData = () => {
store.dispatch('agents/get');
store.dispatch('teams/get');
store.dispatch('labels/get');
};
const formatMacro = macroData => {
const formattedActions = macroData.actions.map(action => {
let actionParams = [];
if (action.action_params.length) {
const inputType = macroActionTypes.find(
item => item.key === action.action_name
).inputType;
if (inputType === 'multi_select' || inputType === 'search_select') {
actionParams = getMacroDropdownValues(action.action_name).filter(item =>
[...action.action_params].includes(item.id)
);
} else if (inputType === 'team_message') {
actionParams = {
team_ids: getMacroDropdownValues(action.action_name).filter(item =>
[...action.action_params[0].team_ids].includes(item.id)
),
message: action.action_params[0].message,
};
});
return {
...macro,
actions: formattedActions,
};
},
initNewMacro() {
this.mode = 'CREATE';
this.macro = {
name: '',
actions: [
{
action_name: 'assign_team',
action_params: [],
},
],
visibility: 'global',
};
},
async saveMacro(macro) {
try {
const action = this.mode === 'EDIT' ? 'macros/update' : 'macros/create';
let successMessage =
this.mode === 'EDIT'
? this.$t('MACROS.EDIT.API.SUCCESS_MESSAGE')
: this.$t('MACROS.ADD.API.SUCCESS_MESSAGE');
let serializedMacro = JSON.parse(JSON.stringify(macro));
serializedMacro.actions = actionQueryGenerator(serializedMacro.actions);
await this.$store.dispatch(action, serializedMacro);
useAlert(successMessage);
this.$router.push({ name: 'macros_wrapper' });
} catch (error) {
useAlert(this.$t('MACROS.ERROR'));
}
},
} else actionParams = [...action.action_params];
}
return {
...action,
action_params: actionParams,
};
});
return {
...macroData,
actions: formattedActions,
};
};
const manifestMacro = async () => {
await store.dispatch('macros/getSingleMacro', macroId.value);
const singleMacro = store.getters['macros/getMacro'](macroId.value);
macro.value = formatMacro(singleMacro);
};
const fetchMacro = () => {
mode.value = 'EDIT';
manifestMacro();
};
const initNewMacro = () => {
mode.value = 'CREATE';
macro.value = {
name: '',
actions: [
{
action_name: 'assign_team',
action_params: [],
},
],
visibility: 'global',
};
};
watch(
() => route,
() => {
fetchDropdownData();
if (route.params.macroId) {
fetchMacro();
} else {
initNewMacro();
}
},
{ immediate: true, deep: true }
);
const saveMacro = async macroData => {
try {
const action = mode.value === 'EDIT' ? 'macros/update' : 'macros/create';
const successMessage =
mode.value === 'EDIT'
? t('MACROS.EDIT.API.SUCCESS_MESSAGE')
: t('MACROS.ADD.API.SUCCESS_MESSAGE');
let serializedMacro = JSON.parse(JSON.stringify(macroData));
serializedMacro.actions = actionQueryGenerator(serializedMacro.actions);
await store.dispatch(action, serializedMacro);
useAlert(successMessage);
router.push({ name: 'macros_wrapper' });
} catch (error) {
useAlert(t('MACROS.ERROR'));
}
};
</script>
@@ -128,11 +125,12 @@ export default {
<div class="flex flex-col flex-1 h-full overflow-auto">
<woot-loading-state
v-if="uiFlags.isFetchingItem"
:message="$t('MACROS.EDITOR.LOADING')"
:message="t('MACROS.EDITOR.LOADING')"
/>
<MacroForm
v-if="macro && !uiFlags.isFetchingItem"
:macro-data.sync="macro"
:macro-data="macro"
@update:macro-data="macro = $event"
@submit="saveMacro"
/>
</div>
@@ -1,84 +1,80 @@
<script>
import { inject } from 'vue';
<script setup>
import { computed, inject } from 'vue';
import { useMacros } from 'dashboard/composables/useMacros';
import { useI18n } from 'dashboard/composables/useI18n';
import ActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
import macrosMixin from 'dashboard/mixins/macrosMixin';
export default {
components: {
ActionInput,
const props = defineProps({
singleNode: {
type: Boolean,
default: false,
},
mixins: [macrosMixin],
props: {
singleNode: {
type: Boolean,
default: false,
},
value: {
type: Object,
default: () => ({}),
},
errorKey: {
type: String,
default: '',
},
fileName: {
type: String,
default: '',
},
value: {
type: Object,
default: () => ({}),
},
setup() {
const macroActionTypes = inject('macroActionTypes');
return { macroActionTypes };
errorKey: {
type: String,
default: '',
},
computed: {
actionData: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value);
},
},
errorMessage() {
if (!this.errorKey) return '';
fileName: {
type: String,
default: '',
},
});
return this.$t(`MACROS.ERRORS.${this.errorKey}`);
},
showActionInput() {
if (
this.actionData.action_name === 'send_email_to_team' ||
this.actionData.action_name === 'send_message'
)
return false;
const type = this.macroActionTypes.find(
action => action.key === this.actionData.action_name
).inputType;
return !!type;
},
},
methods: {
dropdownValues() {
return this.getDropdownValues(this.value.action_name, this.$store);
},
},
const emit = defineEmits(['input', 'resetAction', 'deleteNode']);
const { t } = useI18n();
const macroActionTypes = inject('macroActionTypes');
const { getMacroDropdownValues } = useMacros();
const actionData = computed({
get: () => props.value,
set: value => emit('input', value),
});
const errorMessage = computed(() => {
if (!props.errorKey) return '';
return t(`MACROS.ERRORS.${props.errorKey}`);
});
const showActionInput = computed(() => {
if (
actionData.value.action_name === 'send_email_to_team' ||
actionData.value.action_name === 'send_message'
)
return false;
const type = macroActionTypes.find(
action => action.key === actionData.value.action_name
).inputType;
return !!type;
});
const dropdownValues = () => {
return getMacroDropdownValues(props.value.action_name);
};
</script>
<template>
<div class="macro__node-action-container">
<div class="relative flex items-center w-full min-w-0 basis-full">
<woot-button
v-if="!singleNode"
size="small"
variant="clear"
color-scheme="secondary"
icon="navigation"
class="macros__node-drag-handle"
class="absolute cursor-move -left-8 macros__node-drag-handle"
/>
<div
class="macro__node-action-item"
:class="{
'has-error': errorKey,
}"
class="flex-grow p-2 mr-2 rounded-md shadow-sm"
:class="
errorKey
? 'bg-red-50 animate-shake dark:bg-red-800'
: 'bg-white dark:bg-slate-700'
"
>
<ActionInput
v-model="actionData"
@@ -103,39 +99,3 @@ export default {
/>
</div>
</template>
<style scoped lang="scss">
.macros__node-drag-handle {
@apply cursor-move -left-8 absolute;
}
.macro__node-action-container {
@apply w-full min-w-0 basis-full items-center flex relative;
.macro__node-action-item {
@apply flex-grow bg-white dark:bg-slate-700 p-2 mr-2 rounded-md shadow-sm;
&.has-error {
animation: shake 0.3s ease-in-out 0s 2;
@apply bg-red-50 dark:bg-red-800;
}
}
}
@keyframes shake {
0% {
transform: translateX(0);
}
25% {
transform: translateX(0.234375rem);
}
50% {
transform: translateX(-0.234375rem);
}
75% {
transform: translateX(0.234375rem);
}
100% {
transform: translateX(0);
}
}
</style>
@@ -1,17 +1,23 @@
<script>
import { useAccount } from 'dashboard/composables/useAccount';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import accountMixin from 'dashboard/mixins/account.js';
export default {
components: {
Thumbnail,
},
mixins: [accountMixin],
props: {
macro: {
type: Object,
required: true,
},
},
setup() {
const { accountScopedUrl } = useAccount();
return {
accountScopedUrl,
};
},
computed: {
createdByName() {
const createdBy = this.macro.created_by;
@@ -47,7 +53,7 @@ export default {
</td>
<td>{{ visibilityLabel }}</td>
<td class="button-wrapper">
<router-link :to="addAccountScoping(`settings/macros/${macro.id}/edit`)">
<router-link :to="accountScopedUrl(`settings/macros/${macro.id}/edit`)">
<woot-button
v-tooltip.top="$t('MACROS.EDIT.TOOLTIP')"
variant="smooth"
@@ -1,6 +1,5 @@
<script>
import { useAlert } from 'dashboard/composables';
import configMixin from 'shared/mixins/configMixin';
import { useUISettings } from 'dashboard/composables/useUISettings';
import AudioAlertTone from './AudioAlertTone.vue';
import AudioAlertEvent from './AudioAlertEvent.vue';
@@ -12,7 +11,6 @@ export default {
AudioAlertTone,
AudioAlertCondition,
},
mixins: [configMixin],
setup() {
const { uiSettings, updateUISettings } = useUISettings();
@@ -229,8 +229,11 @@ export default {
</button>
</div>
</FormSection>
<FormSection :title="$t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.TITLE')">
<ChangePassword v-if="!globalConfig.disableUserProfileUpdate" />
<FormSection
v-if="!globalConfig.disableUserProfileUpdate"
:title="$t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.TITLE')"
>
<ChangePassword />
</FormSection>
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.TITLE')"
@@ -1,7 +1,6 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import configMixin from 'shared/mixins/configMixin';
import TableHeaderCell from 'dashboard/components/widgets/TableHeaderCell.vue';
import CheckBox from 'v3/components/Form/CheckBox.vue';
import {
@@ -19,7 +18,6 @@ export default {
FormSwitch,
CheckBox,
},
mixins: [configMixin],
data() {
return {
selectedEmailFlags: [],
@@ -4,14 +4,12 @@ import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.
import { CSAT_RATINGS } from 'shared/constants/messages';
import { mapGetters } from 'vuex';
import { messageStamp, dynamicTime } from 'shared/helpers/timeHelper';
import rtlMixin from 'shared/mixins/rtlMixin';
export default {
components: {
VeTable,
VePagination,
},
mixins: [rtlMixin],
props: {
pageIndex: {
type: Number,
@@ -20,6 +18,7 @@ export default {
},
computed: {
...mapGetters({
isRTL: 'accounts/isRTL',
csatResponses: 'csat/getCSATResponses',
metrics: 'csat/getMetrics',
}),
@@ -29,7 +28,7 @@ export default {
field: 'contact',
key: 'contact',
title: this.$t('CSAT_REPORTS.TABLE.HEADER.CONTACT_NAME'),
align: this.isRTLView ? 'right' : 'left',
align: this.isRTL ? 'right' : 'left',
width: 200,
renderBodyCell: ({ row }) => {
if (row.contact) {
@@ -48,7 +47,7 @@ export default {
field: 'assignedAgent',
key: 'assignedAgent',
title: this.$t('CSAT_REPORTS.TABLE.HEADER.AGENT_NAME'),
align: this.isRTLView ? 'right' : 'left',
align: this.isRTL ? 'right' : 'left',
width: 200,
renderBodyCell: ({ row }) => {
if (row.assignedAgent) {
@@ -78,14 +77,14 @@ export default {
field: 'feedbackText',
key: 'feedbackText',
title: this.$t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'),
align: this.isRTLView ? 'right' : 'left',
align: this.isRTL ? 'right' : 'left',
width: 400,
},
{
field: 'conversationId',
key: 'conversationId',
title: '',
align: this.isRTLView ? 'right' : 'left',
align: this.isRTL ? 'right' : 'left',
width: 100,
renderBodyCell: ({ row }) => {
const routerParams = {
@@ -1,8 +1,9 @@
<script setup>
import { computed } from 'vue';
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
import CardLabels from 'dashboard/components/widgets/conversation/conversationCardComponents/CardLabels.vue';
import SLAViewDetails from './SLAViewDetails.vue';
defineProps({
const props = defineProps({
slaName: {
type: String,
required: true,
@@ -20,6 +21,12 @@ defineProps({
default: () => [],
},
});
const conversationLabels = computed(() => {
return props.conversation.labels
? props.conversation.labels.split(',').map(item => item.trim())
: [];
});
</script>
<template>
@@ -41,7 +48,7 @@ defineProps({
<CardLabels
class="w-[80%]"
:conversation-id="conversationId"
:conversation-labels="conversation.labels"
:conversation-labels="conversationLabels"
/>
</div>
<div
@@ -1,8 +1,8 @@
<script>
import { mapGetters } from 'vuex';
import { VeTable, VePagination } from 'vue-easytable';
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import rtlMixin from 'shared/mixins/rtlMixin';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
@@ -13,7 +13,6 @@ export default {
VeTable,
VePagination,
},
mixins: [rtlMixin],
props: {
agents: {
type: Array,
@@ -33,6 +32,9 @@ export default {
},
},
computed: {
...mapGetters({
isRTL: 'accounts/isRTL',
}),
tableData() {
return this.agentMetrics
.filter(agentMetric => this.getAgentInformation(agentMetric.id))
@@ -57,7 +59,7 @@ export default {
'OVERVIEW_REPORTS.AGENT_CONVERSATIONS.TABLE_HEADER.AGENT'
),
fixed: 'left',
align: this.isRTLView ? 'right' : 'left',
align: this.isRTL ? 'right' : 'left',
width: 25,
renderBodyCell: ({ row }) => (
<div class="row-user-block">
@@ -82,7 +84,7 @@ export default {
title: this.$t(
'OVERVIEW_REPORTS.AGENT_CONVERSATIONS.TABLE_HEADER.OPEN'
),
align: this.isRTLView ? 'right' : 'left',
align: this.isRTL ? 'right' : 'left',
width: 10,
},
{
@@ -91,7 +93,7 @@ export default {
title: this.$t(
'OVERVIEW_REPORTS.AGENT_CONVERSATIONS.TABLE_HEADER.UNATTENDED'
),
align: this.isRTLView ? 'right' : 'left',
align: this.isRTL ? 'right' : 'left',
width: 10,
},
];
@@ -10,7 +10,6 @@ import SLAPaywallEnterprise from './components/SLAPaywallEnterprise.vue';
import { mapGetters } from 'vuex';
import { convertSecondsToTimeUnit } from '@chatwoot/utils';
import { useAlert } from 'dashboard/composables';
import configMixin from 'shared/mixins/configMixin';
export default {
components: {
@@ -22,7 +21,6 @@ export default {
SLAListItemLoading,
SLAPaywallEnterprise,
},
mixins: [configMixin],
data() {
return {
loading: {},
@@ -4,6 +4,7 @@ import AccountAPI from '../../api/account';
import { differenceInDays } from 'date-fns';
import EnterpriseAccountAPI from '../../api/enterprise/account';
import { throwErrorMessage } from '../utils/api';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
const findRecordById = ($state, id) =>
$state.records.find(record => record.id === Number(id)) || {};
@@ -27,6 +28,13 @@ export const getters = {
getUIFlags($state) {
return $state.uiFlags;
},
isRTL: ($state, _, rootState) => {
const accountId = rootState.route?.params?.accountId;
if (!accountId) return false;
const { locale } = findRecordById($state, Number(accountId));
return locale ? getLanguageDirection(locale) : false;
},
isTrialAccount: $state => id => {
const account = findRecordById($state, id);
const createdAt = new Date(account.created_at);
@@ -18,6 +18,15 @@ const getters = {
getCannedResponses(_state) {
return _state.records;
},
getSortedCannedResponses(_state) {
return sortOrder =>
[..._state.records].sort((a, b) => {
if (sortOrder === 'asc') {
return a.short_code.localeCompare(b.short_code);
}
return b.short_code.localeCompare(a.short_code);
});
},
getUIFlags(_state) {
return _state.uiFlags;
},
@@ -1,4 +1,5 @@
import { getters } from '../../accounts';
import * as languageHelpers from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
const accountData = {
id: 1,
@@ -46,4 +47,37 @@ describe('#getters', () => {
)(1, 'auto_resolve_conversations')
).toEqual(true);
});
describe('isRTL', () => {
it('returns false when accountId is not present', () => {
const rootState = { route: { params: {} } };
expect(getters.isRTL({}, null, rootState)).toBe(false);
});
it('returns true for RTL language', () => {
const state = {
records: [{ id: 1, locale: 'ar' }],
};
const rootState = { route: { params: { accountId: '1' } } };
vi.spyOn(languageHelpers, 'getLanguageDirection').mockReturnValue(true);
expect(getters.isRTL(state, null, rootState)).toBe(true);
});
it('returns false for LTR language', () => {
const state = {
records: [{ id: 1, locale: 'en' }],
};
const rootState = { route: { params: { accountId: '1' } } };
vi.spyOn(languageHelpers, 'getLanguageDirection').mockReturnValue(false);
expect(getters.isRTL(state, null, rootState)).toBe(false);
});
it('returns false when account is not found', () => {
const state = {
records: [],
};
const rootState = { route: { params: { accountId: '1' } } };
expect(getters.isRTL(state, null, rootState)).toBe(false);
});
});
});
@@ -0,0 +1,43 @@
import CannedResponses from '../../cannedResponse';
const CANNED_RESPONSES = [
{ short_code: 'hello', content: 'Hi ' },
{ short_code: 'ask', content: 'Ask questions' },
{ short_code: 'greet', content: 'Good morning' },
];
const getters = CannedResponses.getters;
describe('#getCannedResponses', () => {
it('returns canned responses', () => {
const state = { records: CANNED_RESPONSES };
expect(getters.getCannedResponses(state)).toEqual(CANNED_RESPONSES);
});
});
describe('#getSortedCannedResponses', () => {
it('returns sort canned responses in ascending order', () => {
const state = { records: CANNED_RESPONSES };
expect(getters.getSortedCannedResponses(state)('asc')).toEqual([
CANNED_RESPONSES[1],
CANNED_RESPONSES[2],
CANNED_RESPONSES[0],
]);
});
it('returns sort canned responses in descending order', () => {
const state = { records: CANNED_RESPONSES };
expect(getters.getSortedCannedResponses(state)('desc')).toEqual([
CANNED_RESPONSES[0],
CANNED_RESPONSES[2],
CANNED_RESPONSES[1],
]);
});
});
describe('#getUIFlags', () => {
it('returns uiFlags', () => {
const state = { uiFlags: { isFetching: true } };
expect(getters.getUIFlags(state)).toEqual({ isFetching: true });
});
});
@@ -1,59 +1,63 @@
<script>
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
name: 'WootDropdownMenu',
componentName: 'WootDropdownMenu',
<script setup>
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
mixins: [keyboardEventListenerMixins],
props: {
placement: {
type: String,
default: 'top',
},
defineProps({
placement: {
type: String,
default: 'top',
},
methods: {
dropdownMenuButtons() {
return this.$refs.dropdownMenu.querySelectorAll(
'ul.dropdown li.dropdown-menu__item .button'
);
},
getActiveButtonIndex(menuButtons) {
const focusedButton = this.$refs.dropdownMenu.querySelector(
'ul.dropdown li.dropdown-menu__item .button:focus'
);
return Array.from(menuButtons).indexOf(focusedButton);
},
getKeyboardEvents() {
const menuButtons = this.dropdownMenuButtons();
return {
ArrowUp: () => this.focusPreviousButton(menuButtons),
ArrowDown: () => this.focusNextButton(menuButtons),
};
},
focusPreviousButton(menuButtons) {
const activeIndex = this.getActiveButtonIndex(menuButtons);
const newIndex =
activeIndex >= 1 ? activeIndex - 1 : menuButtons.length - 1;
this.focusButton(menuButtons, newIndex);
},
focusNextButton(menuButtons) {
const activeIndex = this.getActiveButtonIndex(menuButtons);
const newIndex =
activeIndex === menuButtons.length - 1 ? 0 : activeIndex + 1;
this.focusButton(menuButtons, newIndex);
},
focusButton(menuButtons, newIndex) {
if (menuButtons.length === 0) return;
menuButtons[newIndex].focus();
},
});
const dropdownMenuRef = ref(null);
const dropdownMenuButtons = () => {
return dropdownMenuRef.value.querySelectorAll(
'ul.dropdown li.dropdown-menu__item .button'
);
};
const getActiveButtonIndex = menuButtons => {
const focusedButton = dropdownMenuRef.value.querySelector(
'ul.dropdown li.dropdown-menu__item .button:focus'
);
return Array.from(menuButtons).indexOf(focusedButton);
};
const focusButton = (menuButtons, newIndex) => {
if (menuButtons.length === 0) return;
menuButtons[newIndex].focus();
};
const focusPreviousButton = menuButtons => {
const activeIndex = getActiveButtonIndex(menuButtons);
const newIndex = activeIndex >= 1 ? activeIndex - 1 : menuButtons.length - 1;
focusButton(menuButtons, newIndex);
};
const focusNextButton = menuButtons => {
const activeIndex = getActiveButtonIndex(menuButtons);
const newIndex = activeIndex === menuButtons.length - 1 ? 0 : activeIndex + 1;
focusButton(menuButtons, newIndex);
};
const keyboardEvents = {
ArrowUp: {
action: () => focusPreviousButton(dropdownMenuButtons()),
allowOnFocusedInput: true,
},
ArrowDown: {
action: () => focusNextButton(dropdownMenuButtons()),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, dropdownMenuRef);
</script>
<template>
<ul
ref="dropdownMenu"
ref="dropdownMenuRef"
class="dropdown menu vertical"
:class="[placement && `dropdown--${placement}`]"
>
@@ -1,22 +1,58 @@
/**
* Checks if a string is a valid E.164 phone number format.
* @param {string} value - The phone number to validate.
* @returns {boolean} True if the number is in E.164 format, false otherwise.
*/
export const isPhoneE164 = value => !!value.match(/^\+[1-9]\d{1,14}$/);
/**
* Validates a phone number after removing the dial code.
* @param {string} value - The full phone number including dial code.
* @param {string} dialCode - The dial code to remove before validation.
* @returns {boolean} True if the number (without dial code) is valid, false otherwise.
*/
export const isPhoneNumberValid = (value, dialCode) => {
const number = value.replace(dialCode, '');
return !!number.match(/^[0-9]{1,14}$/);
};
/**
* Checks if a string is either a valid E.164 phone number or empty.
* @param {string} value - The phone number to validate.
* @returns {boolean} True if the number is in E.164 format or empty, false otherwise.
*/
export const isPhoneE164OrEmpty = value => isPhoneE164(value) || value === '';
/**
* Validates a phone number with dial code, requiring at least 5 digits.
* @param {string} value - The full phone number including dial code.
* @returns {boolean} True if the number is valid, false otherwise.
*/
export const isPhoneNumberValidWithDialCode = value => {
const number = value.replace(/^\+/, ''); // Remove the '+' sign
return !!number.match(/^[1-9]\d{4,}$/); // Validate the phone number with minimum 5 digits
};
/**
* Checks if a string starts with a plus sign.
* @param {string} value - The string to check.
* @returns {boolean} True if the string starts with '+', false otherwise.
*/
export const startsWithPlus = value => value.startsWith('+');
/**
* Checks if a string is a valid URL (starts with 'http') or is empty.
* @param {string} [value=''] - The string to check.
* @returns {boolean} True if the string is a valid URL or empty, false otherwise.
*/
export const shouldBeUrl = (value = '') =>
value ? value.startsWith('http') : true;
/**
* Validates a password for complexity requirements.
* @param {string} value - The password to validate.
* @returns {boolean} True if the password meets all requirements, false otherwise.
*/
export const isValidPassword = value => {
const containsUppercase = /[A-Z]/.test(value);
const containsLowercase = /[a-z]/.test(value);
@@ -32,8 +68,18 @@ export const isValidPassword = value => {
);
};
/**
* Checks if a string consists only of digits.
* @param {string} value - The string to check.
* @returns {boolean} True if the string contains only digits, false otherwise.
*/
export const isNumber = value => /^\d+$/.test(value);
/**
* Validates a domain name.
* @param {string} value - The domain name to validate.
* @returns {boolean} True if the domain is valid or empty, false otherwise.
*/
export const isDomain = value => {
if (value !== '') {
const domainRegex = /^([\p{L}0-9]+(-[\p{L}0-9]+)*\.)+[a-z]{2,}$/gmu;
@@ -41,3 +87,16 @@ export const isDomain = value => {
}
return true;
};
/**
* Creates a RegExp object from a string representation of a regular expression.
* @param {string} regexPatternValue - The string representation of the regex (e.g., '/pattern/flags').
* @returns {RegExp} A RegExp object created from the input string.
*/
export const getRegexp = regexPatternValue => {
let lastSlash = regexPatternValue.lastIndexOf('/');
return new RegExp(
regexPatternValue.slice(1, lastSlash),
regexPatternValue.slice(lastSlash + 1)
);
};
@@ -8,6 +8,7 @@ import {
isPhoneNumberValid,
isNumber,
isDomain,
getRegexp,
} from '../Validators';
describe('#shouldBeUrl', () => {
@@ -115,3 +116,40 @@ describe('#startsWithPlus', () => {
expect(startsWithPlus('123456789')).toEqual(false);
});
});
describe('#getRegexp', () => {
it('should create a correct RegExp object', () => {
const regexPattern = '/^[a-z]+$/i';
const regex = getRegexp(regexPattern);
expect(regex).toBeInstanceOf(RegExp);
expect(regex.toString()).toBe(regexPattern);
expect(regex.test('abc')).toBe(true);
expect(regex.test('ABC')).toBe(true);
expect(regex.test('123')).toBe(false);
});
it('should handle regex with flags', () => {
const regexPattern = '/hello/gi';
const regex = getRegexp(regexPattern);
expect(regex).toBeInstanceOf(RegExp);
expect(regex.toString()).toBe(regexPattern);
expect(regex.test('hello')).toBe(true);
expect(regex.test('HELLO')).toBe(false);
expect(regex.test('Hello World')).toBe(true);
});
it('should handle regex with special characters', () => {
const regexPattern = '/\\d{3}-\\d{2}-\\d{4}/';
const regex = getRegexp(regexPattern);
expect(regex).toBeInstanceOf(RegExp);
expect(regex.toString()).toBe(regexPattern);
expect(regex.test('123-45-6789')).toBe(true);
expect(regex.test('12-34-5678')).toBe(false);
});
});
@@ -1,20 +0,0 @@
export default {
computed: {
hostURL() {
return window.chatwootConfig.hostURL;
},
vapidPublicKey() {
return window.chatwootConfig.vapidPublicKey;
},
enabledLanguages() {
return window.chatwootConfig.enabledLanguages;
},
isEnterprise() {
return window.chatwootConfig.isEnterprise === 'true';
},
enterprisePlanName() {
// returns "community" or "enterprise"
return window.chatwootConfig?.enterprisePlanName;
},
},
};
-27
View File
@@ -1,27 +0,0 @@
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import { useUISettings } from 'dashboard/composables/useUISettings';
export default {
setup() {
const { uiSettings, updateUISettings } = useUISettings();
return {
uiSettings,
updateUISettings,
};
},
computed: {
isRTLView() {
const { rtl_view: isRTLView } = this.uiSettings;
return isRTLView;
},
},
methods: {
updateRTLDirectionView(locale) {
const isRTLSupported = getLanguageDirection(locale);
this.updateUISettings({
rtl_view: isRTLSupported,
});
},
},
};
@@ -1,29 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import rtlMixin from 'shared/mixins/rtlMixin';
import { useUISettings } from 'dashboard/composables/useUISettings';
vi.mock('dashboard/composables/useUISettings');
describe('rtlMixin', () => {
const createComponent = rtl_view => {
useUISettings.mockReturnValue({
uiSettings: { rtl_view },
updateUISettings: vi.fn(),
});
return shallowMount({
render() {},
mixins: [rtlMixin],
});
};
it('returns is direction right-to-left view', () => {
const wrapper = createComponent(true);
expect(wrapper.vm.isRTLView).toBe(true);
});
it('returns is direction left-to-right view', () => {
const wrapper = createComponent(false);
expect(wrapper.vm.isRTLView).toBe(false);
});
});

Some files were not shown because too many files have changed in this diff Show More