Merge branch 'develop' into fix/CW-3385

This commit is contained in:
Muhsin Keloth
2024-08-13 12:45:46 +05:30
committed by GitHub
61 changed files with 1012 additions and 905 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">
@@ -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: {} };
},
@@ -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,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 props.conversationLabels.map(label =>
accountLabels.value.find(l => l.title === label)
);
});
return {
activeLabels,
};
},
data() {
return {
@@ -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,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'],
});
});
});
@@ -17,7 +17,7 @@ export const useStoreGetters = () => {
);
};
export const mapGetter = key => {
export const useMapGetter = key => {
const store = useStore();
return computed(() => store.getters[key]);
};
@@ -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,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,
};
}
@@ -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,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,5 +1,6 @@
<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';
@@ -7,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 {
@@ -17,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
@@ -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;
@@ -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,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>
@@ -2,11 +2,11 @@
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 conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
export default {
components: {
@@ -14,20 +14,17 @@ export default {
LabelDropdown,
AddLabel,
},
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,
required: true,
},
},
setup() {
const { isAdmin } = useAdmin();
const {
savedLabels,
activeLabels,
accountLabels,
addLabelToConversation,
removeLabelFromConversation,
} = useConversationLabels();
const conversationLabelBoxRef = ref(null);
const showSearchDropdownLabel = ref(false);
@@ -58,6 +55,11 @@ export default {
useKeyboardEvents(keyboardEvents, conversationLabelBoxRef);
return {
isAdmin,
savedLabels,
activeLabels,
accountLabels,
addLabelToConversation,
removeLabelFromConversation,
conversationLabelBoxRef,
showSearchDropdownLabel,
closeDropdownLabel,
@@ -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>
@@ -5,13 +5,12 @@ 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 { FEATURE_FLAGS } from '../../../../featureFlags';
import semver from 'semver';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
export default {
mixins: [accountMixin, configMixin],
mixins: [configMixin],
setup() {
const { updateUISettings } = useUISettings();
const v$ = useVuelidate();
@@ -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',
@@ -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"
@@ -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 {
@@ -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,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"
@@ -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,
},
];
@@ -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);
@@ -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);
});
});
});
-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);
});
});
@@ -9,7 +9,7 @@ import FileBubble from 'widget/components/FileBubble.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import configMixin from '../mixins/configMixin';
import messageMixin from '../mixins/messageMixin';
import { useMessage } from '../composables/useMessage';
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
import ReplyToChip from 'widget/components/ReplyToChip.vue';
@@ -28,7 +28,7 @@ export default {
MessageReplyButton,
ReplyToChip,
},
mixins: [configMixin, messageMixin, darkModeMixin],
mixins: [configMixin, darkModeMixin],
props: {
message: {
type: Object,
@@ -39,6 +39,15 @@ export default {
default: () => {},
},
},
setup(props) {
const { messageContentAttributes, hasAttachments } = useMessage(
props.message
);
return {
messageContentAttributes,
hasAttachments,
};
},
data() {
return {
hasImageError: false,
@@ -6,7 +6,7 @@ import VideoBubble from 'widget/components/VideoBubble.vue';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import FileBubble from 'widget/components/FileBubble.vue';
import { messageStamp } from 'shared/helpers/timeHelper';
import messageMixin from '../mixins/messageMixin';
import { useMessage } from '../composables/useMessage';
import ReplyToChip from 'widget/components/ReplyToChip.vue';
import DragWrapper from 'widget/components/DragWrapper.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
@@ -25,7 +25,6 @@ export default {
ReplyToChip,
DragWrapper,
},
mixins: [messageMixin],
props: {
message: {
type: Object,
@@ -36,6 +35,12 @@ export default {
default: () => {},
},
},
setup(props) {
const { hasAttachments } = useMessage(props.message);
return {
hasAttachments,
};
},
data() {
return {
hasImageError: false,
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { reactive, nextTick } from 'vue';
import { useMessage } from '../useMessage';
describe('useMessage', () => {
it('should handle deleted messages', () => {
const message = reactive({
content_attributes: { deleted: true },
attachments: [],
});
const { messageContentAttributes, hasAttachments } = useMessage(message);
expect(messageContentAttributes.value).toEqual({ deleted: true });
expect(hasAttachments.value).toBe(false);
});
it('should handle non-deleted messages with attachments', () => {
const message = reactive({
content_attributes: {},
attachments: ['attachment1', 'attachment2'],
});
const { messageContentAttributes, hasAttachments } = useMessage(message);
expect(messageContentAttributes.value).toEqual({});
expect(hasAttachments.value).toBe(true);
});
it('should handle messages without content_attributes', () => {
const message = reactive({
attachments: [],
});
const { messageContentAttributes, hasAttachments } = useMessage(message);
expect(messageContentAttributes.value).toEqual({});
expect(hasAttachments.value).toBe(false);
});
it('should handle messages with empty attachments array', () => {
const message = reactive({
content_attributes: { someAttribute: 'value' },
attachments: [],
});
const { messageContentAttributes, hasAttachments } = useMessage(message);
expect(messageContentAttributes.value).toEqual({ someAttribute: 'value' });
expect(hasAttachments.value).toBe(false);
});
it('should update reactive properties when message changes', async () => {
const message = reactive({
content_attributes: {},
attachments: [],
});
const { messageContentAttributes, hasAttachments } = useMessage(message);
expect(messageContentAttributes.value).toEqual({});
expect(hasAttachments.value).toBe(false);
message.content_attributes = { updated: true };
message.attachments.push('newAttachment');
await nextTick();
expect(messageContentAttributes.value).toEqual({ updated: true });
expect(hasAttachments.value).toBe(true);
});
});
@@ -0,0 +1,22 @@
import { computed } from 'vue';
/**
* Composable for handling message-related computations.
* @param {Object} message - The message object to be processed.
* @returns {Object} An object containing computed properties for message content and attachments.
*/
export function useMessage(message) {
const messageContentAttributes = computed(() => {
const { content_attributes: attribute = {} } = message;
return attribute;
});
const hasAttachments = computed(() => {
return !!(message.attachments && message.attachments.length > 0);
});
return {
messageContentAttributes,
hasAttachments,
};
}
@@ -1,13 +0,0 @@
export default {
computed: {
messageContentAttributes() {
const { content_attributes: attribute = {} } = this.message;
return attribute;
},
hasAttachments() {
return !!(
this.message.attachments && this.message.attachments.length > 0
);
},
},
};
@@ -1,37 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import messageMixin from '../messageMixin';
import messages from './messageFixture';
describe('messageMixin', () => {
let Component = {
render() {},
title: 'TestComponent',
mixins: [messageMixin],
};
it('deleted messages', async () => {
const wrapper = shallowMount(Component, {
data() {
return {
message: messages.deletedMessage,
};
},
});
expect(wrapper.vm.messageContentAttributes).toEqual({
deleted: true,
});
expect(wrapper.vm.hasAttachments).toBe(false);
});
it('non-deleted messages', async () => {
const wrapper = shallowMount(Component, {
data() {
return {
message: messages.nonDeletedMessage,
};
},
});
expect(wrapper.vm.deletedMessage).toBe(undefined);
expect(wrapper.vm.messageContentAttributes).toEqual({});
expect(wrapper.vm.hasAttachments).toBe(true);
});
});