Compare commits

..
Author SHA1 Message Date
Fayaz Ahmed 5bce6707d2 chore: Replace availability mixin 2024-09-10 02:22:07 +05:30
46 changed files with 1064 additions and 632 deletions
@@ -8,7 +8,6 @@ import {
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -56,7 +55,7 @@ export default {
return {
editorView: null,
state: undefined,
plugins: [imagePastePlugin(this.handleImageUpload)],
plugins: [],
};
},
computed: {
@@ -77,7 +76,6 @@ export default {
this.reloadState();
},
},
created() {
this.state = createState(
this.value,
@@ -97,24 +95,6 @@ export default {
openFileBrowser() {
this.$refs.imageUploadInput.click();
},
async handleImageUpload(url) {
try {
const fileUrl = await this.$store.dispatch(
'articles/uploadExternalImage',
{
portalSlug: this.$route.params.portalSlug,
url,
}
);
return fileUrl;
} catch (error) {
useAlert(
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.UN_AUTHORIZED_ERROR')
);
return '';
}
},
onFileChange() {
const file = this.$refs.imageUploadInput.files[0];
@@ -1,93 +1,52 @@
import { uploadFile } from '../uploadHelper';
import axios from 'axios';
import { uploadExternalImage, uploadFile } from '../uploadHelper';
global.axios = axios;
vi.mock('axios');
describe('Upload Helpers', () => {
describe('#Upload Helpers', () => {
afterEach(() => {
// Cleaning up the mock after each test
axios.post.mockReset();
});
describe('uploadFile', () => {
it('should send a POST request with correct data', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
const mockResponse = {
data: {
file_url: 'https://example.com/fileUrl',
blob_key: 'blobKey123',
blob_id: 'blobId456',
},
};
axios.post.mockResolvedValueOnce(mockResponse);
const result = await uploadFile(mockFile, '1602');
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/accounts/1602/upload',
expect.any(FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
expect(result).toEqual({
fileUrl: 'https://example.com/fileUrl',
blobKey: 'blobKey123',
blobId: 'blobId456',
});
it('should send a POST request with correct data', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
const mockResponse = {
data: {
file_url: 'https://example.com/fileUrl',
blob_key: 'blobKey123',
blob_id: 'blobId456',
},
};
it('should handle errors', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
const mockError = new Error('Failed to upload');
axios.post.mockResolvedValueOnce(mockResponse);
axios.post.mockRejectedValueOnce(mockError);
const result = await uploadFile(mockFile, '1602');
await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload');
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/accounts/1602/upload',
expect.any(FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
expect(result).toEqual({
fileUrl: 'https://example.com/fileUrl',
blobKey: 'blobKey123',
blobId: 'blobId456',
});
});
describe('uploadExternalImage', () => {
it('should send a POST request with correct data', async () => {
const mockUrl = 'https://example.com/image.jpg';
const mockResponse = {
data: {
file_url: 'https://example.com/fileUrl',
blob_key: 'blobKey123',
blob_id: 'blobId456',
},
};
axios.post.mockResolvedValueOnce(mockResponse);
const result = await uploadExternalImage(mockUrl, '1602');
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/accounts/1602/upload',
{ external_url: mockUrl },
{ headers: { 'Content-Type': 'application/json' } }
);
expect(result).toEqual({
fileUrl: 'https://example.com/fileUrl',
blobKey: 'blobKey123',
blobId: 'blobId456',
});
it('should handle errors', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
const mockError = new Error('Failed to upload');
it('should handle errors', async () => {
const mockUrl = 'https://example.com/image.jpg';
const mockError = new Error('Failed to upload');
axios.post.mockRejectedValueOnce(mockError);
axios.post.mockRejectedValueOnce(mockError);
await expect(uploadExternalImage(mockUrl)).rejects.toThrow(
'Failed to upload'
);
});
await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload');
});
});
@@ -19,47 +19,26 @@ const HEADERS = {
* The function uses FormData to wrap the file and axios to send the request.
*
* @param {File} file - The file to be uploaded. It should be a File object (typically coming from a file input element).
* @param {string} accountId - The account ID.
* @returns {Promise} A promise that resolves with the server's response when the upload is successful, or rejects if there's an error.
*/
export async function uploadFile(file, accountId) {
// Create a new FormData instance.
let formData = new FormData();
if (!accountId) {
accountId = window.location.pathname.split('/')[3];
}
// Append the file to the FormData instance under the key 'attachment'.
let formData = new FormData();
formData.append('attachment', file);
// Use axios to send a POST request to the upload endpoint.
const { data } = await axios.post(
`/api/${API_VERSION}/accounts/${accountId}/upload`,
formData,
{ headers: HEADERS }
);
return {
fileUrl: data.file_url,
blobKey: data.blob_key,
blobId: data.blob_id,
};
}
/**
* Uploads an image from an external URL.
*
* @param {string} url - The external URL of the image.
* @param {string} accountId - The account ID.
* @returns {Promise} A promise that resolves with the server's response.
*/
export async function uploadExternalImage(url, accountId) {
if (!accountId) {
accountId = window.location.pathname.split('/')[3];
}
const { data } = await axios.post(
`/api/${API_VERSION}/accounts/${accountId}/upload`,
{ external_url: url },
{ headers: { 'Content-Type': 'application/json' } }
{
headers: HEADERS,
}
);
return {
@@ -41,7 +41,6 @@
"UPLOADING": "Uploading...",
"SUCCESS": "Image uploaded successfully",
"ERROR": "Error while uploading image",
"UN_AUTHORIZED_ERROR": "You are not authorized to upload images",
"ERROR_FILE_SIZE": "Image size should be less than {size}MB",
"ERROR_FILE_FORMAT": "Image format should be jpg, jpeg or png",
"ERROR_FILE_DIMENSIONS": "Image dimensions should be less than 2000 x 2000"
@@ -1,5 +1,5 @@
import articlesAPI from 'dashboard/api/helpCenter/articles';
import { uploadExternalImage, uploadFile } from 'dashboard/helper/uploadHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import types from '../../mutation-types';
@@ -132,11 +132,6 @@ export const actions = {
return fileUrl;
},
uploadExternalImage: async (_, { url }) => {
const { fileUrl } = await uploadExternalImage(url);
return fileUrl;
},
reorder: async (_, { portalSlug, categorySlug, reorderedGroup }) => {
try {
await articlesAPI.reorderArticles({
@@ -1,7 +1,7 @@
import axios from 'axios';
import { uploadExternalImage, uploadFile } from 'dashboard/helper/uploadHelper';
import * as types from '../../../mutation-types';
import { actions } from '../actions';
import * as types from '../../../mutation-types';
import { uploadFile } from 'dashboard/helper/uploadHelper';
vi.mock('dashboard/helper/uploadHelper');
@@ -211,29 +211,4 @@ describe('#actions', () => {
);
});
});
describe('uploadExternalImage', () => {
it('should upload the image from external URL and return the fileUrl', async () => {
const mockUrl = 'https://example.com/image.jpg';
const mockFileUrl = 'https://uploaded.example.com/image.jpg';
uploadExternalImage.mockResolvedValueOnce({ fileUrl: mockFileUrl });
// When
const result = await actions.uploadExternalImage({}, { url: mockUrl });
// Then
expect(uploadExternalImage).toHaveBeenCalledWith(mockUrl);
expect(result).toBe(mockFileUrl);
});
it('should throw an error if the upload fails', async () => {
const mockUrl = 'https://example.com/image.jpg';
const mockError = new Error('Upload failed');
uploadExternalImage.mockRejectedValueOnce(mockError);
await expect(
actions.uploadExternalImage({}, { url: mockUrl })
).rejects.toThrow('Upload failed');
});
});
});
+6 -14
View File
@@ -1,11 +1,12 @@
<script>
import CardButton from 'shared/components/CardButton.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default {
components: {
CardButton,
},
mixins: [darkModeMixin],
props: {
title: {
type: String,
@@ -24,30 +25,21 @@ export default {
default: () => [],
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
computed: {},
};
</script>
<template>
<div
class="card-message chat-bubble agent"
:class="getThemeClass('bg-white', 'dark:bg-slate-700')"
:class="$dm('bg-white', 'dark:bg-slate-700')"
>
<img class="media" :src="mediaUrl" />
<div class="card-body">
<h4
class="title"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
>
<h4 class="title" :class="$dm('text-black-900', 'dark:text-slate-50')">
{{ title }}
</h4>
<p
class="body"
:class="getThemeClass('text-black-700', 'dark:text-slate-100')"
>
<p class="body" :class="$dm('text-black-700', 'dark:text-slate-100')">
{{ description }}
</p>
<CardButton v-for="action in actions" :key="action.id" :action="action" />
@@ -1,9 +1,10 @@
<script>
import { mapGetters } from 'vuex';
import { getContrastingTextColor } from '@chatwoot/utils';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default {
mixins: [darkModeMixin],
props: {
buttonLabel: {
type: String,
@@ -18,10 +19,6 @@ export default {
default: () => [],
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
data() {
return {
formValues: {},
@@ -36,8 +33,8 @@ export default {
return getContrastingTextColor(this.widgetColor);
},
inputColor() {
return `${this.getThemeClass('bg-white', 'dark:bg-slate-600')}
${this.getThemeClass('text-black-900', 'dark:text-slate-50')}`;
return `${this.$dm('bg-white', 'dark:bg-slate-600')}
${this.$dm('text-black-900', 'dark:text-slate-50')}`;
},
isFormValid() {
return this.items.reduce((acc, { name }) => {
@@ -83,7 +80,7 @@ export default {
<template>
<div
class="form chat-bubble agent"
:class="getThemeClass('bg-white', 'dark:bg-slate-700')"
:class="$dm('bg-white', 'dark:bg-slate-700')"
>
<form @submit.prevent="onSubmit">
<div
@@ -94,7 +91,7 @@ export default {
'has-submitted': hasSubmitted,
}"
>
<label :class="getThemeClass('text-black-900', 'dark:text-slate-50')">{{
<label :class="$dm('text-black-900', 'dark:text-slate-50')">{{
item.label
}}</label>
<input
@@ -3,7 +3,7 @@ import { mapGetters } from 'vuex';
import Spinner from 'shared/components/Spinner.vue';
import { CSAT_RATINGS } from 'shared/constants/messages';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import { getContrastingTextColor } from '@chatwoot/utils';
export default {
@@ -11,6 +11,7 @@ export default {
Spinner,
FluentIcon,
},
mixins: [darkModeMixin],
props: {
messageContentAttributes: {
type: Object,
@@ -21,10 +22,6 @@ export default {
required: true,
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
data() {
return {
email: '',
@@ -47,8 +44,8 @@ export default {
return !(this.selectedRating && this.feedback);
},
inputColor() {
return `${this.getThemeClass('bg-white', 'dark:bg-slate-600')}
${this.getThemeClass('text-black-900', 'dark:text-slate-50')}`;
return `${this.$dm('bg-white', 'dark:bg-slate-600')}
${this.$dm('text-black-900', 'dark:text-slate-50')}`;
},
textColor() {
return getContrastingTextColor(this.widgetColor);
@@ -108,13 +105,10 @@ export default {
<template>
<div
class="customer-satisfaction"
:class="getThemeClass('bg-white', 'dark:bg-slate-700')"
:class="$dm('bg-white', 'dark:bg-slate-700')"
:style="{ borderColor: widgetColor }"
>
<h6
class="title"
:class="getThemeClass('text-slate-900', 'dark:text-slate-50')"
>
<h6 class="title" :class="$dm('text-slate-900', 'dark:text-slate-50')">
{{ title }}
</h6>
<div class="ratings">
@@ -1,18 +1,15 @@
<script>
import { formatDate } from 'shared/helpers/DateHelper';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default {
mixins: [darkModeMixin],
props: {
date: {
type: String,
required: true,
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
computed: {
formattedDate() {
return formatDate({
@@ -28,7 +25,7 @@ export default {
<template>
<div
class="date--separator"
:class="getThemeClass('text-slate-700', 'dark:text-slate-200')"
:class="$dm('text-slate-700', 'dark:text-slate-200')"
>
{{ formattedDate }}
</div>
@@ -2,6 +2,7 @@ import DateSeparator from '../DateSeparator.vue';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
const localVue = createLocalVue();
import i18n from 'dashboard/i18n';
localVue.use(Vuex);
@@ -39,6 +40,7 @@ describe('dateSeparator', () => {
propsData: { date: 'Nov 18, 2019' },
mocks: { $t: msg => msg },
i18n: i18nConfig,
mixins: [darkModeMixin],
});
});
+6 -7
View File
@@ -4,11 +4,11 @@ import { setHeader } from 'widget/helpers/axios';
import addHours from 'date-fns/addHours';
import { IFrameHelper, RNHelper } from 'widget/helpers/utils';
import configMixin from './mixins/configMixin';
import availabilityMixin from 'widget/mixins/availability';
import { useAvailability } from 'widget/composables/useAvailability';
import { getLocale } from './helpers/urlParamsHelper';
import { isEmptyObject } from 'widget/helpers/utils';
import Spinner from 'shared/components/Spinner.vue';
import useReplaceRoute from 'widget/composables/useReplaceRoute';
import routerMixin from './mixins/routerMixin';
import {
getExtraSpaceToScroll,
loadedEventConfig,
@@ -18,7 +18,7 @@ import {
ON_CAMPAIGN_MESSAGE_CLICK,
ON_UNREAD_MESSAGE_CLICK,
} from './constants/widgetBusEvents';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import { SDK_SET_BUBBLE_VISIBILITY } from '../shared/constants/sharedFrameEvents';
import { emitter } from 'shared/helpers/mitt';
@@ -27,11 +27,10 @@ export default {
components: {
Spinner,
},
mixins: [availabilityMixin, configMixin],
mixins: [configMixin, routerMixin, darkModeMixin],
setup() {
const { prefersDarkMode } = useDarkMode();
const replaceRoute = useReplaceRoute();
return { replaceRoute, prefersDarkMode };
const { isInBusinessHours } = useAvailability();
return { isInBusinessHours };
},
data() {
return {
@@ -11,7 +11,7 @@ import { MESSAGE_TYPE } from 'widget/helpers/constants';
import configMixin from '../mixins/configMixin';
import messageMixin from '../mixins/messageMixin';
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
import ReplyToChip from 'widget/components/ReplyToChip.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
@@ -28,7 +28,7 @@ export default {
MessageReplyButton,
ReplyToChip,
},
mixins: [configMixin, messageMixin],
mixins: [configMixin, messageMixin, darkModeMixin],
props: {
message: {
type: Object,
@@ -39,12 +39,6 @@ export default {
default: () => {},
},
},
setup() {
const { getThemeClass } = useDarkMode();
return {
getThemeClass,
};
},
data() {
return {
hasImageError: false,
@@ -198,9 +192,7 @@ export default {
<div
v-if="hasAttachments"
class="space-y-2 chat-bubble has-attachment agent"
:class="
(wrapClass, getThemeClass('bg-white', 'dark:bg-slate-700'))
"
:class="(wrapClass, $dm('bg-white', 'dark:bg-slate-700'))"
>
<div
v-for="attachment in message.attachments"
@@ -239,7 +231,7 @@ export default {
v-if="message.showAvatar || hasRecordedResponse"
v-dompurify-html="agentName"
class="agent-name"
:class="getThemeClass('text-slate-700', 'dark:text-slate-200')"
:class="$dm('text-slate-700', 'dark:text-slate-200')"
/>
</div>
</div>
@@ -6,7 +6,7 @@ import ChatOptions from 'shared/components/ChatOptions.vue';
import ChatArticle from './template/Article.vue';
import EmailInput from './template/EmailInput.vue';
import CustomerSatisfaction from 'shared/components/CustomerSatisfaction.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
import IntegrationCard from './template/IntegrationCard.vue';
export default {
@@ -20,6 +20,7 @@ export default {
CustomerSatisfaction,
IntegrationCard,
},
mixins: [darkModeMixin],
props: {
message: { type: String, default: null },
contentType: { type: String, default: null },
@@ -33,13 +34,11 @@ export default {
setup() {
const { formatMessage, getPlainText, truncateMessage, highlightContent } =
useMessageFormatter();
const { getThemeClass } = useDarkMode();
return {
formatMessage,
getPlainText,
truncateMessage,
highlightContent,
getThemeClass,
};
},
computed: {
@@ -99,7 +98,7 @@ export default {
!isCards && !isOptions && !isForm && !isArticle && !isCards && !isCSAT
"
class="chat-bubble agent"
:class="getThemeClass('bg-white', 'dark:bg-slate-700 has-dark-mode')"
:class="$dm('bg-white', 'dark:bg-slate-700 has-dark-mode')"
>
<div
v-dompurify-html="formatMessage(message, false)"
@@ -1,11 +1,8 @@
<script>
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixing from 'widget/mixins/darkModeMixin.js';
export default {
name: 'AgentTypingBubble',
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
mixins: [darkModeMixing],
};
</script>
@@ -16,7 +13,7 @@ export default {
<div class="message-wrap mt-2">
<div
class="typing-bubble chat-bubble agent"
:class="getThemeClass('bg-white', 'dark:bg-slate-700')"
:class="$dm('bg-white', 'dark:bg-slate-700')"
>
<img
src="~widget/assets/images/typing.gif"
@@ -6,7 +6,7 @@ import FooterReplyTo from 'widget/components/FooterReplyTo.vue';
import ChatInputWrap from 'widget/components/ChatInputWrap.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { sendEmailTranscript } from 'widget/api/conversation';
import useReplaceRoute from 'widget/composables/useReplaceRoute';
import routerMixin from 'widget/mixins/routerMixin';
import { IFrameHelper } from '../helpers/utils';
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
@@ -16,10 +16,7 @@ export default {
CustomButton,
FooterReplyTo,
},
setup() {
const replaceRoute = useReplaceRoute();
return { replaceRoute };
},
mixins: [routerMixin],
data() {
return {
inReplyTo: null,
+24 -27
View File
@@ -1,12 +1,10 @@
<script>
import availabilityMixin from 'widget/mixins/availability';
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
import { useAvailability } from 'widget/composables/useAvailability';
import { useNextAvailabilityTime } from 'widget/composables/useNextAvailabilityTime';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import HeaderActions from './HeaderActions.vue';
import useReplaceRoute from 'widget/composables/useReplaceRoute';
import darkMixin from 'widget/mixins/darkModeMixin.js';
import routerMixin from 'widget/mixins/routerMixin';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkMixin from 'widget/mixins/darkModeMixin.js';
export default {
name: 'ChatHeader',
@@ -14,7 +12,7 @@ export default {
FluentIcon,
HeaderActions,
},
mixins: [nextAvailabilityTime, availabilityMixin],
mixins: [routerMixin, darkMixin],
props: {
avatarUrl: {
type: String,
@@ -32,26 +30,25 @@ export default {
type: Boolean,
default: false,
},
availableAgents: {
type: Array,
default: () => {},
},
},
setup() {
const replaceRoute = useReplaceRoute();
const { getThemeClass } = useDarkMode();
return { getThemeClass, replaceRoute };
const {
isOnline,
channelConfig,
isInBetweenTheWorkingHours,
replyWaitMessage,
} = useAvailability();
const { setTimeSlot } = useNextAvailabilityTime();
return {
isOnline,
channelConfig,
isInBetweenTheWorkingHours,
replyWaitMessage,
setTimeSlot,
};
},
computed: {
isOnline() {
const { workingHoursEnabled } = this.channelConfig;
const anyAgentOnline = this.availableAgents.length > 0;
if (workingHoursEnabled) {
return this.isInBetweenTheWorkingHours;
}
return anyAgentOnline;
},
mounted() {
this.setTimeSlot();
},
methods: {
onBackButtonClick() {
@@ -64,7 +61,7 @@ export default {
<template>
<header
class="flex justify-between w-full p-5"
:class="getThemeClass('bg-white', 'dark:bg-slate-900')"
:class="$dm('bg-white', 'dark:bg-slate-900')"
>
<div class="flex items-center">
<button
@@ -75,7 +72,7 @@ export default {
<FluentIcon
icon="chevron-left"
size="24"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
</button>
<img
@@ -87,7 +84,7 @@ export default {
<div>
<div
class="flex items-center text-base font-medium leading-4"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
:class="$dm('text-black-900', 'dark:text-slate-50')"
>
<span v-dompurify-html="title" class="mr-1" />
<div
@@ -97,7 +94,7 @@ export default {
</div>
<div
class="mt-1 text-xs leading-3"
:class="getThemeClass('text-black-700', 'dark:text-slate-400')"
:class="$dm('text-black-700', 'dark:text-slate-400')"
>
{{ replyWaitMessage }}
</div>
@@ -1,12 +1,13 @@
<script>
import HeaderActions from './HeaderActions.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default {
name: 'ChatHeaderExpanded',
components: {
HeaderActions,
},
mixins: [darkModeMixin],
props: {
avatarUrl: {
type: String,
@@ -25,10 +26,6 @@ export default {
default: false,
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
};
</script>
@@ -54,12 +51,12 @@ export default {
<h2
v-dompurify-html="introHeading"
class="mt-4 text-2xl mb-1.5 font-medium"
:class="getThemeClass('text-slate-900', 'dark:text-slate-50')"
:class="$dm('text-slate-900', 'dark:text-slate-50')"
/>
<p
v-dompurify-html="introBody"
class="text-base leading-normal"
:class="getThemeClass('text-slate-700', 'dark:text-slate-200')"
:class="$dm('text-slate-700', 'dark:text-slate-200')"
/>
</header>
</template>
@@ -6,7 +6,7 @@ import ChatSendButton from 'widget/components/ChatSendButton.vue';
import configMixin from '../mixins/configMixin';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
const EmojiInput = () => import('shared/components/emoji/EmojiInput.vue');
@@ -19,7 +19,7 @@ export default {
FluentIcon,
ResizableTextArea,
},
mixins: [configMixin],
mixins: [configMixin, darkModeMixin],
props: {
onSendMessage: {
type: Function,
@@ -30,10 +30,7 @@ export default {
default: () => {},
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
data() {
return {
userInput: '',
@@ -54,16 +51,13 @@ export default {
return this.userInput.length > 0;
},
inputColor() {
return `${this.getThemeClass('bg-white', 'dark:bg-slate-600')}
${this.getThemeClass('text-black-900', 'dark:text-slate-50')}`;
return `${this.$dm('bg-white', 'dark:bg-slate-600')}
${this.$dm('text-black-900', 'dark:text-slate-50')}`;
},
emojiIconColor() {
return this.showEmojiPicker
? `text-woot-500 ${this.getThemeClass(
'text-black-900',
'dark:text-slate-100'
)}`
: `${this.getThemeClass('text-black-900', 'dark:text-slate-100')}`;
? `text-woot-500 ${this.$dm('text-black-900', 'dark:text-slate-100')}`
: `${this.$dm('text-black-900', 'dark:text-slate-100')}`;
},
},
watch: {
@@ -134,7 +128,7 @@ export default {
<template>
<div
class="chat-message--input is-focused"
:class="getThemeClass('bg-white ', 'dark:bg-slate-600')"
:class="$dm('bg-white ', 'dark:bg-slate-600')"
@keydown.esc="hideEmojiPicker"
>
<ResizableTextArea
@@ -154,7 +148,7 @@ export default {
<div class="button-wrap">
<ChatAttachmentButton
v-if="showAttachment"
:class="getThemeClass('text-black-900', 'dark:text-slate-100')"
:class="$dm('text-black-900', 'dark:text-slate-100')"
:on-attach="onSendAttachment"
/>
<button
@@ -3,7 +3,7 @@ import ChatMessage from 'widget/components/ChatMessage.vue';
import AgentTypingBubble from 'widget/components/AgentTypingBubble.vue';
import DateSeparator from 'shared/components/DateSeparator.vue';
import Spinner from 'shared/components/Spinner.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { mapActions, mapGetters } from 'vuex';
@@ -15,16 +15,13 @@ export default {
DateSeparator,
Spinner,
},
mixins: [darkModeMixin],
props: {
groupedMessages: {
type: Array,
default: () => [],
},
},
setup() {
const { darkMode } = useDarkMode();
return { darkMode };
},
data() {
return {
previousScrollHeight: 0,
@@ -1,12 +1,13 @@
<script>
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
import { getContrastingTextColor } from '@chatwoot/utils';
export default {
components: {
FluentIcon,
},
mixins: [darkModeMixin],
props: {
url: {
type: String,
@@ -25,10 +26,6 @@ export default {
default: false,
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
computed: {
title() {
return this.isInProgress
@@ -48,7 +45,7 @@ export default {
},
titleColor() {
return !this.isUserBubble
? this.getThemeClass('text-black-900', 'dark:text-slate-50')
? this.$dm('text-black-900', 'dark:text-slate-50')
: '';
},
},
@@ -1,6 +1,7 @@
<script>
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default {
mixins: [darkModeMixin],
props: {
label: {
type: String,
@@ -27,33 +28,20 @@ export default {
default: '',
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
computed: {
labelClass() {
return this.error
? `text-red-400 ${this.getThemeClass(
'text-black-800',
'dark:text-slate-50'
)}`
: `text-black-800 ${this.getThemeClass(
'text-black-800',
'dark:text-slate-50'
)}`;
? `text-red-400 ${this.$dm('text-black-800', 'dark:text-slate-50')}`
: `text-black-800 ${this.$dm('text-black-800', 'dark:text-slate-50')}`;
},
isInputDarkOrLightMode() {
return `${this.getThemeClass(
'bg-white',
'dark:bg-slate-600'
)} ${this.getThemeClass('text-slate-700', 'dark:text-slate-50')}`;
return `${this.$dm('bg-white', 'dark:bg-slate-600')} ${this.$dm(
'text-slate-700',
'dark:text-slate-50'
)}`;
},
inputBorderColor() {
return `${this.getThemeClass(
'border-black-200',
'dark:border-black-500'
)}`;
return `${this.$dm('border-black-200', 'dark:border-black-500')}`;
},
inputHasError() {
return this.error
@@ -2,13 +2,13 @@
import countries from 'shared/constants/countries.js';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import FormulateInputMixin from '@braid/vue-formulate/src/FormulateInputMixin';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default {
components: {
FluentIcon,
},
mixins: [FormulateInputMixin],
mixins: [FormulateInputMixin, darkModeMixin],
props: {
placeholder: {
type: String,
@@ -19,10 +19,6 @@ export default {
default: false,
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
data() {
return {
selectedIndex: -1,
@@ -49,31 +45,28 @@ export default {
return this.activeCountryCode ? 'Clear selection' : 'Select Country';
},
dropdownClass() {
return `${this.getThemeClass(
'bg-slate-100',
'dark:bg-slate-700'
)} ${this.getThemeClass('text-slate-700', 'dark:text-slate-50')}`;
},
dropdownBackgroundClass() {
return `${this.getThemeClass(
'bg-white',
'dark:bg-slate-700'
)} ${this.getThemeClass('text-slate-700', 'dark:text-slate-50')}`;
},
dropdownItemClass() {
return `${this.getThemeClass(
return `${this.$dm('bg-slate-100', 'dark:bg-slate-700')} ${this.$dm(
'text-slate-700',
'dark:text-slate-50'
)} ${this.getThemeClass('hover:bg-slate-50', 'dark:hover:bg-slate-600')}`;
},
activeDropdownItemClass() {
return `active ${this.getThemeClass(
'bg-slate-100',
'dark:bg-slate-800'
)}`;
},
dropdownBackgroundClass() {
return `${this.$dm('bg-white', 'dark:bg-slate-700')} ${this.$dm(
'text-slate-700',
'dark:text-slate-50'
)}`;
},
dropdownItemClass() {
return `${this.$dm('text-slate-700', 'dark:text-slate-50')} ${this.$dm(
'hover:bg-slate-50',
'dark:hover:bg-slate-600'
)}`;
},
activeDropdownItemClass() {
return `active ${this.$dm('bg-slate-100', 'dark:bg-slate-800')}`;
},
focusedDropdownItemClass() {
return `focus ${this.getThemeClass('bg-slate-50', 'dark:bg-slate-600')}`;
return `focus ${this.$dm('bg-slate-50', 'dark:bg-slate-600')}`;
},
inputHasError() {
return this.hasErrorInPhoneInput
@@ -81,16 +74,13 @@ export default {
: `hover:border-black-300 focus:border-black-300 ${this.inputLightAndDarkModeColor} ${this.inputBorderColor}`;
},
inputBorderColor() {
return `${this.getThemeClass(
'border-black-200',
'dark:border-black-500'
)}`;
return `${this.$dm('border-black-200', 'dark:border-black-500')}`;
},
inputLightAndDarkModeColor() {
return `${this.getThemeClass(
'bg-white',
'dark:bg-slate-600'
)} ${this.getThemeClass('text-slate-700', 'dark:text-slate-50')}`;
return `${this.$dm('bg-white', 'dark:bg-slate-600')} ${this.$dm(
'text-slate-700',
'dark:text-slate-50'
)}`;
},
items() {
return this.countries.filter(country => {
@@ -251,7 +241,7 @@ export default {
<span
v-if="activeDialCode"
class="py-2 pl-2 pr-0 text-base"
:class="getThemeClass('text-slate-700', 'dark:text-slate-50')"
:class="$dm('text-slate-700', 'dark:text-slate-50')"
>
{{ activeDialCode }}
</span>
@@ -283,10 +273,7 @@ export default {
type="text"
:placeholder="$t('PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.DROPDOWN_SEARCH')"
class="w-full h-8 px-3 py-2 mt-1 mb-1 text-sm border border-solid rounded outline-none dropdown-search"
:class="[
getThemeClass('bg-slate-50', 'dark:bg-slate-600'),
inputBorderColor,
]"
:class="[$dm('bg-slate-50', 'dark:bg-slate-600'), inputBorderColor]"
/>
</div>
<div
@@ -311,7 +298,7 @@ export default {
<div v-if="items.length === 0">
<span
class="flex justify-center mt-4 text-sm text-center"
:class="getThemeClass('text-slate-700', 'dark:text-slate-50')"
:class="$dm('text-slate-700', 'dark:text-slate-50')"
>
{{ $t('PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.DROPDOWN_EMPTY') }}
</span>
@@ -1,6 +1,7 @@
<script>
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default {
mixins: [darkModeMixin],
props: {
label: {
type: String,
@@ -19,33 +20,20 @@ export default {
default: '',
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
computed: {
labelClass() {
return this.error
? `text-red-400 ${this.getThemeClass(
'text-black-800',
'dark:text-slate-50'
)}`
: `text-black-800 ${this.getThemeClass(
'text-black-800',
'dark:text-slate-50'
)}`;
? `text-red-400 ${this.$dm('text-black-800', 'dark:text-slate-50')}`
: `text-black-800 ${this.$dm('text-black-800', 'dark:text-slate-50')}`;
},
isTextAreaDarkOrLightMode() {
return `${this.getThemeClass(
'bg-white',
'dark:bg-slate-600'
)} ${this.getThemeClass('text-slate-700', 'dark:text-slate-50')}`;
return `${this.$dm('bg-white', 'dark:bg-slate-600')} ${this.$dm(
'text-slate-700',
'dark:text-slate-50'
)}`;
},
textAreaBorderColor() {
return `${this.getThemeClass(
'border-black-200',
'dark:border-black-500'
)}`;
return `${this.$dm('border-black-200', 'dark:border-black-500')}`;
},
isTextAreaHasError() {
return this.error
@@ -3,14 +3,14 @@ import { mapGetters } from 'vuex';
import { IFrameHelper, RNHelper } from 'widget/helpers/utils';
import { popoutChatWindow } from '../helpers/popoutHelper';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import configMixin from 'widget/mixins/configMixin';
import { CONVERSATION_STATUS } from 'shared/constants/messages';
export default {
name: 'HeaderActions',
components: { FluentIcon },
mixins: [configMixin],
mixins: [configMixin, darkModeMixin],
props: {
showPopoutButton: {
type: Boolean,
@@ -21,10 +21,6 @@ export default {
default: true,
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
computed: {
...mapGetters({
conversationAttributes: 'conversationAttributes/getConversationParams',
@@ -96,7 +92,7 @@ export default {
<FluentIcon
icon="sign-out"
size="22"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
</button>
<button
@@ -107,7 +103,7 @@ export default {
<FluentIcon
icon="open"
size="22"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
</button>
<button
@@ -120,7 +116,7 @@ export default {
<FluentIcon
icon="dismiss"
size="24"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
</button>
</div>
@@ -5,20 +5,17 @@ import { mapGetters } from 'vuex';
import { getContrastingTextColor } from '@chatwoot/utils';
import { isEmptyObject } from 'widget/helpers/utils';
import { getRegexp } from 'shared/helpers/Validators';
import useReplaceRoute from 'widget/composables/useReplaceRoute';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import routerMixin from 'widget/mixins/routerMixin';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import configMixin from 'widget/mixins/configMixin';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
export default {
components: {
CustomButton,
Spinner,
},
mixins: [configMixin],
mixins: [routerMixin, darkModeMixin, configMixin],
props: {
options: {
type: Object,
@@ -27,9 +24,9 @@ export default {
},
setup() {
const { formatMessage } = useMessageFormatter();
const replaceRoute = useReplaceRoute();
const { getThemeClass } = useDarkMode();
return { formatMessage, getThemeClass, replaceRoute };
return {
formatMessage,
};
},
data() {
return {
@@ -134,28 +131,25 @@ export default {
return `mt-1 border rounded w-full py-2 px-3 text-slate-700 outline-none`;
},
isInputDarkOrLightMode() {
return `${this.getThemeClass(
'bg-white',
'dark:bg-slate-600'
)} ${this.getThemeClass('text-slate-700', 'dark:text-slate-50')}`;
return `${this.$dm('bg-white', 'dark:bg-slate-600')} ${this.$dm(
'text-slate-700',
'dark:text-slate-50'
)}`;
},
inputBorderColor() {
return `${this.getThemeClass(
'border-black-200',
'dark:border-black-500'
)}`;
return `${this.$dm('border-black-200', 'dark:border-black-500')}`;
},
},
methods: {
labelClass(context) {
const { hasErrors } = context;
if (!hasErrors) {
return `text-xs font-medium ${this.getThemeClass(
return `text-xs font-medium ${this.$dm(
'text-black-800',
'dark:text-slate-50'
)}`;
}
return `text-xs font-medium ${this.getThemeClass(
return `text-xs font-medium ${this.$dm(
'text-red-400',
'dark:text-red-400'
)}`;
@@ -270,7 +264,7 @@ export default {
v-if="shouldShowHeaderMessage"
v-dompurify-html="formatMessage(headerMessage, false)"
class="mb-4 text-sm leading-5 pre-chat-header-message"
:class="getThemeClass('text-black-800', 'dark:text-slate-50')"
:class="$dm('text-black-800', 'dark:text-slate-50')"
/>
<FormulateInput
v-for="item in enabledPreChatFields"
@@ -1,10 +1,10 @@
<script>
import { mapGetters } from 'vuex';
import { getContrastingTextColor } from '@chatwoot/utils';
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
import { useNextAvailabilityTime } from 'widget/composables/useNextAvailabilityTime';
import AvailableAgents from 'widget/components/AvailableAgents.vue';
import configMixin from 'widget/mixins/configMixin';
import availabilityMixin from 'widget/mixins/availability';
import { useAvailability } from 'widget/composables/useAvailability';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { IFrameHelper } from 'widget/helpers/utils';
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
@@ -15,7 +15,7 @@ export default {
AvailableAgents,
FluentIcon,
},
mixins: [configMixin, nextAvailabilityTime, availabilityMixin],
mixins: [configMixin],
props: {
availableAgents: {
type: Array,
@@ -26,7 +26,11 @@ export default {
default: false,
},
},
setup() {
const { isInBetweenTheWorkingHours, replyWaitMessage } = useAvailability();
const { setTimeSlot } = useNextAvailabilityTime();
return { setTimeSlot, isInBetweenTheWorkingHours, replyWaitMessage };
},
computed: {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
@@ -44,6 +48,9 @@ export default {
return anyAgentOnline;
},
},
mounted() {
this.setTimeSlot();
},
methods: {
startConversation() {
this.$emit('startConversation');
@@ -7,11 +7,11 @@ import {
ON_CAMPAIGN_MESSAGE_CLICK,
ON_UNREAD_MESSAGE_CLICK,
} from '../constants/widgetBusEvents';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default {
name: 'UnreadMessage',
components: { Thumbnail },
mixins: [configMixin],
mixins: [configMixin, darkModeMixin],
props: {
message: {
type: String,
@@ -31,15 +31,9 @@ export default {
},
},
setup() {
const { formatMessage, getPlainText, truncateMessage, highlightContent } =
useMessageFormatter();
const { getThemeClass } = useDarkMode();
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
getPlainText,
truncateMessage,
highlightContent,
getThemeClass,
};
},
computed: {
@@ -97,7 +91,7 @@ export default {
<div class="chat-bubble-wrap">
<button
class="chat-bubble agent"
:class="getThemeClass('bg-white', 'dark:bg-slate-50')"
:class="$dm('bg-white', 'dark:bg-slate-50')"
@click="onClickMessage"
>
<div v-if="showSender" class="row--agent-block">
@@ -1,12 +1,13 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default {
components: {
FluentIcon,
},
mixins: [darkModeMixin],
props: {
items: {
type: Array,
@@ -15,8 +16,9 @@ export default {
},
setup() {
const { truncateMessage } = useMessageFormatter();
const { getThemeClass } = useDarkMode();
return { getThemeClass, truncateMessage };
return {
truncateMessage,
};
},
};
</script>
@@ -25,7 +27,7 @@ export default {
<div
v-if="!!items.length"
class="chat-bubble agent"
:class="getThemeClass('bg-white', 'dark:bg-slate-700')"
:class="$dm('bg-white', 'dark:bg-slate-700')"
>
<div v-for="item in items" :key="item.link" class="article-item">
<a :href="item.link" target="_blank" rel="noopener noreferrer nofollow">
@@ -33,16 +35,15 @@ export default {
<FluentIcon
icon="link"
class="mr-1"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
<span
:class="getThemeClass('text-slate-900', 'dark:text-slate-50')"
>{{ item.title }}</span
>
<span :class="$dm('text-slate-900', 'dark:text-slate-50')">{{
item.title
}}</span>
</span>
<span
class="description"
:class="getThemeClass('text-slate-700', 'dark:text-slate-200')"
:class="$dm('text-slate-700', 'dark:text-slate-200')"
>
{{ truncateMessage(item.description) }}
</span>
@@ -6,13 +6,14 @@ import { getContrastingTextColor } from '@chatwoot/utils';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import Spinner from 'shared/components/Spinner.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default {
components: {
FluentIcon,
Spinner,
},
mixins: [darkModeMixin],
props: {
messageId: {
type: Number,
@@ -24,8 +25,7 @@ export default {
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { v$: useVuelidate(), getThemeClass };
return { v$: useVuelidate() };
},
data() {
return {
@@ -47,9 +47,9 @@ export default {
);
},
inputColor() {
return `${this.getThemeClass('bg-white', 'dark:bg-slate-600')}
${this.getThemeClass('text-black-900', 'dark:text-slate-50')}
${this.getThemeClass('border-black-200', 'dark:border-black-500')}`;
return `${this.$dm('bg-white', 'dark:bg-slate-600')}
${this.$dm('text-black-900', 'dark:text-slate-50')}
${this.$dm('border-black-200', 'dark:border-black-500')}`;
},
inputHasError() {
return this.v$.email.$error
@@ -0,0 +1,118 @@
import { ref } from 'vue';
import { useAvailability } from '../useAvailability';
import * as useNextAvailabilityTime from '../useNextAvailabilityTime';
import * as useI18n from 'dashboard/composables/useI18n';
import * as useMapGetter from 'dashboard/composables/store';
vi.mock('../useNextAvailabilityTime');
vi.mock('dashboard/composables/useI18n');
vi.mock('dashboard/composables/store');
describe('useAvailability', () => {
beforeEach(() => {
vi.useFakeTimers();
useNextAvailabilityTime.useNextAvailabilityTime.mockReturnValue({
timeLeftToBackInOnline: ref('2 hours'),
});
useI18n.useI18n.mockReturnValue({
t: vi.fn(key => key),
});
useMapGetter.useMapGetter.mockReturnValue(ref([]));
global.chatwootWebChannel = {
workingHoursEnabled: true,
workingHours: [
{
day_of_week: 3,
closed_all_day: false,
open_hour: 8,
open_minutes: 30,
close_hour: 17,
close_minutes: 35,
open_all_day: false,
},
{
day_of_week: 4,
closed_all_day: false,
open_hour: 8,
open_minutes: 30,
close_hour: 17,
close_minutes: 30,
open_all_day: false,
},
],
utcOffset: '-07:00',
replyTime: 'in_a_few_hours',
};
});
afterEach(() => {
vi.useRealTimers();
});
it('returns valid isInBetweenTheWorkingHours if in different timezone', () => {
vi.setSystemTime(new Date('Thu Apr 14 2022 06:04:46 GMT+0530'));
const { isInBetweenTheWorkingHours } = useAvailability();
expect(isInBetweenTheWorkingHours.value).toBe(true);
});
it('returns valid isInBetweenTheWorkingHours if in same timezone', () => {
global.chatwootWebChannel.utcOffset = '+05:30';
vi.setSystemTime(new Date('Thu Apr 14 2022 09:01:46 GMT+0530'));
const { isInBetweenTheWorkingHours } = useAvailability();
expect(isInBetweenTheWorkingHours.value).toBe(true);
});
it('returns false if closed all day', () => {
global.chatwootWebChannel.utcOffset = '-07:00';
global.chatwootWebChannel.workingHours = [
{ day_of_week: 3, closed_all_day: true },
];
vi.setSystemTime(new Date('Thu Apr 14 2022 09:01:46 GMT+0530'));
const { isInBetweenTheWorkingHours } = useAvailability();
expect(isInBetweenTheWorkingHours.value).toBe(false);
});
it('returns true if open all day', () => {
global.chatwootWebChannel.utcOffset = '-07:00';
global.chatwootWebChannel.workingHours = [
{ day_of_week: 3, open_all_day: true },
];
vi.setSystemTime(new Date('Thu Apr 14 2022 09:01:46 GMT+0530'));
const { isInBetweenTheWorkingHours } = useAvailability();
expect(isInBetweenTheWorkingHours.value).toBe(true);
});
it('returns correct replyTimeStatus', () => {
const { replyTimeStatus } = useAvailability();
expect(replyTimeStatus.value).toBe('REPLY_TIME.IN_A_FEW_HOURS');
});
it('returns correct replyWaitMessage when online', () => {
global.chatwootWebChannel.workingHoursEnabled = true;
global.chatwootWebChannel.utcOffset = '+05:30';
vi.setSystemTime(new Date('Thu Apr 14 2022 09:01:46 GMT+0530'));
const { replyWaitMessage, isInBetweenTheWorkingHours } = useAvailability();
expect(isInBetweenTheWorkingHours.value).toBe(true);
expect(replyWaitMessage.value).toBe('REPLY_TIME.IN_A_FEW_HOURS');
});
it('returns correct replyWaitMessage when offline', () => {
global.chatwootWebChannel.workingHoursEnabled = true;
global.chatwootWebChannel.workingHours = [
{ day_of_week: 3, closed_all_day: true },
];
vi.setSystemTime(new Date('Thu Apr 14 2022 09:01:46 GMT+0530'));
const { replyWaitMessage } = useAvailability();
expect(replyWaitMessage.value).toBe('REPLY_TIME.BACK_IN 2 hours');
});
it('returns correct isInBusinessHours', () => {
global.chatwootWebChannel.workingHoursEnabled = true;
global.chatwootWebChannel.utcOffset = '+05:30';
vi.setSystemTime(new Date('Thu Apr 14 2022 09:01:46 GMT+0530'));
const { isInBusinessHours, isInBetweenTheWorkingHours } = useAvailability();
expect(isInBetweenTheWorkingHours.value).toBe(true);
expect(isInBusinessHours.value).toBe(true);
});
});
@@ -1,71 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useDarkMode } from '../useDarkMode';
import { useMapGetter } from 'dashboard/composables/store';
vi.mock('dashboard/composables/store', () => ({
useMapGetter: vi.fn(),
}));
describe('useDarkMode', () => {
let mockDarkMode;
beforeEach(() => {
mockDarkMode = { value: 'light' };
vi.mocked(useMapGetter).mockReturnValue(mockDarkMode);
});
it('returns darkMode, prefersDarkMode, and getThemeClass', () => {
const result = useDarkMode();
expect(result).toHaveProperty('darkMode');
expect(result).toHaveProperty('prefersDarkMode');
expect(result).toHaveProperty('getThemeClass');
});
describe('prefersDarkMode', () => {
it('returns false when darkMode is light', () => {
const { prefersDarkMode } = useDarkMode();
expect(prefersDarkMode.value).toBe(false);
});
it('returns true when darkMode is dark', () => {
mockDarkMode.value = 'dark';
const { prefersDarkMode } = useDarkMode();
expect(prefersDarkMode.value).toBe(true);
});
it('returns true when darkMode is auto and OS prefers dark mode', () => {
mockDarkMode.value = 'auto';
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true });
const { prefersDarkMode } = useDarkMode();
expect(prefersDarkMode.value).toBe(true);
});
it('returns false when darkMode is auto and OS prefers light mode', () => {
mockDarkMode.value = 'auto';
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: false });
const { prefersDarkMode } = useDarkMode();
expect(prefersDarkMode.value).toBe(false);
});
});
describe('getThemeClass', () => {
it('returns light class when darkMode is light', () => {
const { getThemeClass } = useDarkMode();
expect(getThemeClass('light-class', 'dark-class')).toBe('light-class');
});
it('returns dark class when darkMode is dark', () => {
mockDarkMode.value = 'dark';
const { getThemeClass } = useDarkMode();
expect(getThemeClass('light-class', 'dark-class')).toBe('dark-class');
});
it('returns both classes when darkMode is auto', () => {
mockDarkMode.value = 'auto';
const { getThemeClass } = useDarkMode();
expect(getThemeClass('light-class', 'dark-class')).toBe(
'light-class dark-class'
);
});
});
});
@@ -0,0 +1,275 @@
import { useNextAvailabilityTime } from '../useNextAvailabilityTime';
import { useI18n } from 'dashboard/composables/useI18n';
import { generateRelativeTime } from 'shared/helpers/DateHelper';
import { utcToZonedTime } from 'date-fns-tz';
vi.mock('dashboard/composables/useI18n');
describe('useNextAvailabilityTime', () => {
const chatwootWebChannel = {
workingHoursEnabled: true,
workingHours: [
{
day_of_week: 0,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
{
day_of_week: 1,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
{
day_of_week: 2,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
{
day_of_week: 3,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
{
day_of_week: 4,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
{
day_of_week: 5,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
{
day_of_week: 6,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
],
timezone: 'UTC',
locale: 'en',
};
beforeEach(() => {
window.chatwootWebChannel = chatwootWebChannel;
useI18n.mockReturnValue({
t: vi.fn(key => {
if (key === 'DAY_NAMES') {
return [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
}
return key;
}),
});
generateRelativeTime.mockImplementation((value, unit) => {
if (unit === 'hour') return `in ${value} hour${value > 1 ? 's' : ''}`;
if (unit === 'minutes')
return `in ${value} minute${value > 1 ? 's' : ''}`;
if (unit === 'days') return value === 1 ? 'tomorrow' : `in ${value} days`;
return `in ${value} ${unit}`;
});
utcToZonedTime.mockImplementation(date => new Date(date));
});
afterEach(() => {
delete window.chatwootWebChannel;
vi.clearAllMocks();
});
it('should return day names', () => {
const { dayNames } = useNextAvailabilityTime();
expect(dayNames).toEqual([
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
]);
});
it('should return channelConfig', () => {
const { channelConfig } = useNextAvailabilityTime();
expect(channelConfig.value).toEqual(chatwootWebChannel);
});
it('should return workingHours', () => {
const { workingHours } = useNextAvailabilityTime();
expect(workingHours.value).toEqual(chatwootWebChannel.workingHours);
});
it('should return currentDayWorkingHours', () => {
const { currentDayWorkingHours } = useNextAvailabilityTime();
const currentDay = new Date().getDay();
const expectedWorkingHours = chatwootWebChannel.workingHours.find(
slot => slot.day_of_week === currentDay
);
expect(currentDayWorkingHours.value).toEqual(expectedWorkingHours);
});
it('should return nextDayWorkingHours', () => {
const { nextDayWorkingHours } = useNextAvailabilityTime();
const currentDay = new Date().getDay();
const nextDay = currentDay === 6 ? 0 : currentDay + 1;
const expectedWorkingHours = chatwootWebChannel.workingHours.find(
slot => slot.day_of_week === nextDay
);
expect(nextDayWorkingHours.value).toEqual(expectedWorkingHours);
});
it('should return presentHour', () => {
const { presentHour } = useNextAvailabilityTime();
expect(presentHour.value).toBe(new Date().getUTCHours());
});
it('should return presentMinute', () => {
const { presentMinute } = useNextAvailabilityTime();
expect(presentMinute.value).toBe(new Date().getUTCMinutes());
});
it('should return currentDay', () => {
const { currentDay } = useNextAvailabilityTime();
const date = new Date();
const day = date.getUTCDay();
expect(currentDay.value).toBe(day);
});
it('should return currentDayTimings', () => {
const { currentDayTimings, currentDayWorkingHours } =
useNextAvailabilityTime();
const {
open_hour: openHour,
open_minutes: openMinute,
close_hour: closeHour,
} = currentDayWorkingHours.value;
expect(currentDayTimings.value).toEqual({
openHour,
openMinute,
closeHour,
});
});
it('should return nextDayTimings', () => {
const { nextDayTimings, nextDayWorkingHours } = useNextAvailabilityTime();
const { open_hour: openHour, open_minutes: openMinute } =
nextDayWorkingHours.value;
expect(nextDayTimings.value).toEqual({
openHour,
openMinute,
});
});
it('should return dayDiff', () => {
const { dayDiff, currentDay, nextDayWorkingHours } =
useNextAvailabilityTime();
const nextDay = nextDayWorkingHours.value.day_of_week;
const totalDays = 6;
const expectedDayDiff =
nextDay > currentDay.value
? nextDay - currentDay.value - 1
: totalDays - currentDay.value + nextDay;
expect(dayDiff.value).toEqual(expectedDayDiff);
});
it('should return dayNameOfNextWorkingDay', () => {
const { dayNameOfNextWorkingDay, nextDayWorkingHours, dayNames } =
useNextAvailabilityTime();
const nextDay = nextDayWorkingHours.value.day_of_week;
const expectedDayName = dayNames[nextDay];
expect(dayNameOfNextWorkingDay.value).toEqual(expectedDayName);
});
it('should return hoursAndMinutesBackInOnline', () => {
const { hoursAndMinutesBackInOnline } = useNextAvailabilityTime();
expect(hoursAndMinutesBackInOnline.value).toBeDefined();
expect(typeof hoursAndMinutesBackInOnline.value.hoursLeft).toBe('number');
expect(typeof hoursAndMinutesBackInOnline.value.minutesLeft).toBe('number');
});
// Time-based tests
describe('time-based tests', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should return in 30 minutes', () => {
vi.setSystemTime(new Date('2022-04-14T17:30:00.000Z')); // Thursday
chatwootWebChannel.workingHours[4].open_hour = 18;
chatwootWebChannel.workingHours[4].open_minutes = 0;
chatwootWebChannel.workingHours[4].close_hour = 23;
const { timeLeftToBackInOnline, setTimeSlot } = useNextAvailabilityTime();
setTimeSlot();
expect(timeLeftToBackInOnline.value).toBe('in 30 minutes');
});
it('should return in 2 hours', () => {
vi.setSystemTime(new Date('2022-04-14T17:00:00.000Z')); // Thursday
chatwootWebChannel.workingHours[4].open_hour = 19;
chatwootWebChannel.workingHours[4].open_minutes = 0;
chatwootWebChannel.workingHours[4].close_hour = 23;
const { timeLeftToBackInOnline, setTimeSlot } = useNextAvailabilityTime();
setTimeSlot();
expect(timeLeftToBackInOnline.value).toBe('in 2 hours');
});
it('should return at 10:00 AM', () => {
vi.setSystemTime(new Date('2022-04-14T22:00:00.000Z')); // Thursday
chatwootWebChannel.workingHours[5].open_hour = 10; // Friday
chatwootWebChannel.workingHours[5].open_minutes = 0;
chatwootWebChannel.workingHours[5].close_hour = 18;
const { timeLeftToBackInOnline, setTimeSlot } = useNextAvailabilityTime();
setTimeSlot();
expect(timeLeftToBackInOnline.value).toBe('at 10:00 AM');
});
it('should return tomorrow', () => {
vi.setSystemTime(new Date('2022-04-14T22:00:00.000Z')); // Thursday
chatwootWebChannel.workingHours[5].open_hour = 9; // Friday
chatwootWebChannel.workingHours[5].open_minutes = 0;
chatwootWebChannel.workingHours[5].close_hour = 17;
const { timeLeftToBackInOnline, setTimeSlot } = useNextAvailabilityTime();
setTimeSlot();
expect(timeLeftToBackInOnline.value).toBe('tomorrow');
});
it('should return on Saturday', () => {
vi.setSystemTime(new Date('2022-04-14T22:00:00.000Z')); // Thursday
chatwootWebChannel.workingHours[4].open_hour = 9;
chatwootWebChannel.workingHours[4].close_hour = 17;
chatwootWebChannel.workingHours[5].closed_all_day = true; // Friday closed
const { timeLeftToBackInOnline, setTimeSlot } = useNextAvailabilityTime();
setTimeSlot();
expect(timeLeftToBackInOnline.value).toBe('on Saturday');
});
});
});
@@ -1,51 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { useReplaceRoute } from '../useReplaceRoute';
vi.mock('dashboard/composables/route', () => ({
useRouter: vi.fn(),
useRoute: vi.fn(),
}));
import { useRouter, useRoute } from 'dashboard/composables/route';
describe('useReplaceRoute', () => {
const mockReplace = vi.fn();
const mockRouter = { replace: mockReplace };
beforeEach(() => {
vi.clearAllMocks();
useRouter.mockReturnValue(mockRouter);
});
it('should replace route when current route is different', () => {
useRoute.mockReturnValue({ name: 'currentRoute' });
const replaceRoute = useReplaceRoute();
replaceRoute('newRoute', { id: 1 });
expect(mockReplace).toHaveBeenCalledWith({
name: 'newRoute',
params: { id: 1 },
});
});
it('should not replace route when current route is the same', () => {
useRoute.mockReturnValue({ name: 'sameRoute' });
const replaceRoute = useReplaceRoute();
const result = replaceRoute('sameRoute');
expect(mockReplace).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it('should return the result of router.replace when replacing route', () => {
useRoute.mockReturnValue({ name: 'currentRoute' });
mockReplace.mockReturnValue('replacementResult');
const replaceRoute = useReplaceRoute();
const result = replaceRoute('newRoute');
expect(result).toBe('replacementResult');
});
});
@@ -0,0 +1,122 @@
import { computed, ref } from 'vue';
import { utcToZonedTime } from 'date-fns-tz';
import { isTimeAfter } from 'shared/helpers/DateHelper';
import { useI18n } from 'dashboard/composables/useI18n';
import { useNextAvailabilityTime } from './useNextAvailabilityTime';
import { useMapGetter } from 'dashboard/composables/store';
export const useAvailability = () => {
const availableAgents = useMapGetter('agent/availableAgents');
const { t } = useI18n();
const { timeLeftToBackInOnline } = useNextAvailabilityTime();
const channelConfig = ref(window.chatwootWebChannel);
const replyTimeStatus = computed(() => {
switch (channelConfig.value.replyTime) {
case 'in_a_few_minutes':
return t('REPLY_TIME.IN_A_FEW_MINUTES');
case 'in_a_few_hours':
return t('REPLY_TIME.IN_A_FEW_HOURS');
case 'in_a_day':
return t('REPLY_TIME.IN_A_DAY');
default:
return t('REPLY_TIME.IN_A_FEW_HOURS');
}
});
function getDateWithOffset(utcOffset) {
return utcToZonedTime(new Date().toISOString(), utcOffset);
}
const currentDayAvailability = computed(() => {
const { utcOffset, workingHours } = channelConfig.value;
const dayOfTheWeek = getDateWithOffset(utcOffset).getDay();
const [workingHourConfig = {}] = workingHours.filter(
workingHour => workingHour.day_of_week === dayOfTheWeek
);
return {
closedAllDay: workingHourConfig.closed_all_day,
openHour: workingHourConfig.open_hour,
openMinute: workingHourConfig.open_minutes,
closeHour: workingHourConfig.close_hour,
closeMinute: workingHourConfig.close_minutes,
openAllDay: workingHourConfig.open_all_day,
};
});
const isInBetweenTheWorkingHours = computed(() => {
const {
openHour,
openMinute,
closeHour,
closeMinute,
closedAllDay,
openAllDay,
} = currentDayAvailability.value;
if (openAllDay) return true;
if (closedAllDay) return false;
const { utcOffset } = channelConfig.value;
const today = getDateWithOffset(utcOffset);
const currentHours = today.getHours();
const currentMinutes = today.getMinutes();
const isAfterStartTime = isTimeAfter(
currentHours,
currentMinutes,
openHour,
openMinute
);
const isBeforeEndTime = isTimeAfter(
closeHour,
closeMinute,
currentHours,
currentMinutes
);
return isAfterStartTime && isBeforeEndTime;
});
const isOnline = computed(() => {
const { workingHoursEnabled } = channelConfig.value;
const anyAgentOnline = availableAgents.value.length > 0;
if (workingHoursEnabled) {
return isInBetweenTheWorkingHours.value;
}
return anyAgentOnline;
});
const replyWaitMessage = computed(() => {
const { workingHoursEnabled } = channelConfig.value;
if (workingHoursEnabled) {
return isOnline.value
? replyTimeStatus.value
: `${t('REPLY_TIME.BACK_IN')} ${timeLeftToBackInOnline.value}`;
}
return isOnline.value
? replyTimeStatus.value
: t('TEAM_AVAILABILITY.OFFLINE');
});
const outOfOfficeMessage = computed(
() => channelConfig.value.outOfOfficeMessage
);
const isInBusinessHours = computed(() => {
const { workingHoursEnabled } = channelConfig.value;
return workingHoursEnabled ? isInBetweenTheWorkingHours.value : true;
});
return {
channelConfig,
replyTimeStatus,
currentDayAvailability,
isInBetweenTheWorkingHours,
isOnline,
replyWaitMessage,
outOfOfficeMessage,
isInBusinessHours,
};
};
@@ -1,39 +0,0 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
const isDarkModeAuto = mode => mode === 'auto';
const isDarkMode = mode => mode === 'dark';
const getSystemPreference = () =>
window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
const calculatePrefersDarkMode = (mode, systemPreference) =>
isDarkModeAuto(mode) ? systemPreference : isDarkMode(mode);
const calculateThemeClass = (mode, light, dark) => {
if (isDarkModeAuto(mode)) return `${light} ${dark}`;
return isDarkMode(mode) ? dark : light;
};
/**
* Composable for handling dark mode.
* @returns {Object} An object containing computed properties and methods for dark mode.
*/
export function useDarkMode() {
const darkMode = useMapGetter('appConfig/darkMode');
const systemPreference = computed(getSystemPreference);
const prefersDarkMode = computed(() =>
calculatePrefersDarkMode(darkMode.value, systemPreference.value)
);
const getThemeClass = (light, dark) =>
calculateThemeClass(darkMode.value, light, dark);
return {
darkMode,
prefersDarkMode,
getThemeClass,
};
}
@@ -0,0 +1,245 @@
import { ref, computed } from 'vue';
import {
timeSlotParse,
defaultTimeSlot,
} from 'dashboard/routes/dashboard/settings/inbox/helpers/businessHour.js';
import { utcToZonedTime } from 'date-fns-tz';
import { generateRelativeTime } from 'shared/helpers/DateHelper';
import { useI18n } from 'dashboard/composables/useI18n';
const MINUTE_ROUNDING_FACTOR = 5;
export const useNextAvailabilityTime = () => {
const { t } = useI18n();
const dayNames = t('DAY_NAMES');
const timeSlots = ref([...defaultTimeSlot]);
const timeSlot = ref({});
const channelConfig = computed(() => window.chatwootWebChannel);
const workingHours = computed(() => channelConfig.value.workingHours);
const timeZoneValue = computed(() => channelConfig.value.timezone);
const languageCode = computed(() => window.chatwootWebChannel.locale);
const newDateWithTimeZone = computed(() =>
utcToZonedTime(new Date(), timeZoneValue.value)
);
const presentHour = computed(() => newDateWithTimeZone.value.getHours());
const presentMinute = computed(() => newDateWithTimeZone.value.getMinutes());
const currentDay = computed(() => {
const date = newDateWithTimeZone.value;
const day = date.getDay();
const getCurrentDay = Object.keys(dayNames).find(
key => dayNames[key] === dayNames[day]
);
return Number(getCurrentDay);
});
const currentDayWorkingHours = computed(() =>
workingHours.value.find(slot => slot.day_of_week === currentDay.value)
);
function getNextDay(day) {
return (day + 1) % 7;
}
function getNextWorkingHour(day) {
const workingHour = workingHours.value.find(
slot => slot.day_of_week === day
);
if (workingHour && !workingHour.closed_all_day) {
return workingHour;
}
return null;
}
const nextDayWorkingHours = computed(() => {
let nextDay = getNextDay(currentDay.value);
let nextWorkingHour = getNextWorkingHour(nextDay);
while (!nextWorkingHour) {
nextDay = getNextDay(nextDay);
nextWorkingHour = getNextWorkingHour(nextDay);
}
return nextWorkingHour;
});
const currentDayTimings = computed(() => {
const {
open_hour: openHour,
open_minutes: openMinute,
close_hour: closeHour,
} = currentDayWorkingHours.value ?? {};
return { openHour, openMinute, closeHour };
});
const nextDayTimings = computed(() => {
const { open_hour: openHour, open_minutes: openMinute } =
nextDayWorkingHours.value ?? {};
return { openHour, openMinute };
});
const dayDiff = computed(() => {
const nextDay = nextDayWorkingHours.value.day_of_week;
const totalDays = 6;
return nextDay > currentDay.value
? nextDay - currentDay.value - 1
: totalDays - currentDay.value + nextDay;
});
const dayNameOfNextWorkingDay = computed(
() => dayNames[nextDayWorkingHours.value.day_of_week]
);
function getHoursAndMinutesUntilNextDayOpen(
openHour,
openMinutes,
closeHour
) {
if (closeHour < openHour) {
openHour += 24;
}
let diffMinutes =
openHour * 60 +
openMinutes -
(presentHour.value * 60 + presentMinute.value);
diffMinutes = diffMinutes < 0 ? diffMinutes + 24 * 60 : diffMinutes;
const [hoursLeft, minutesLeft] = [
Math.floor(diffMinutes / 60),
diffMinutes % 60,
];
return { hoursLeft, minutesLeft };
}
const hoursAndMinutesBackInOnline = computed(() => {
if (presentHour.value >= currentDayTimings.value.closeHour) {
return getHoursAndMinutesUntilNextDayOpen(
nextDayWorkingHours.value.open_all_day
? 0
: nextDayTimings.value.openHour,
nextDayTimings.value.openMinute,
currentDayTimings.value.closeHour
);
}
return getHoursAndMinutesUntilNextDayOpen(
currentDayTimings.value.openHour,
currentDayTimings.value.openMinute,
currentDayTimings.value.closeHour
);
});
const exactTimeInAmPm = computed(
() =>
`${
timeSlot.value.day === currentDay.value
? `at ${timeSlot.value.from}`
: ''
}`
);
const hoursAndMinutesLeft = computed(() => {
const { hoursLeft, minutesLeft } = hoursAndMinutesBackInOnline.value;
const timeLeftChars = [];
if (hoursLeft > 0) {
const roundedUpHoursLeft = minutesLeft > 0 ? hoursLeft + 1 : hoursLeft;
const hourRelative = generateRelativeTime(
roundedUpHoursLeft,
'hour',
languageCode.value
);
timeLeftChars.push(`${hourRelative}`);
}
if (minutesLeft > 0 && hoursLeft === 0) {
const roundedUpMinLeft =
Math.ceil(minutesLeft / MINUTE_ROUNDING_FACTOR) *
MINUTE_ROUNDING_FACTOR;
const minRelative = generateRelativeTime(
roundedUpMinLeft,
'minutes',
languageCode.value
);
timeLeftChars.push(`${minRelative}`);
}
return timeLeftChars.join(' ');
});
const hoursAndMinutesToBack = computed(() => {
const { hoursLeft, minutesLeft } = hoursAndMinutesBackInOnline.value;
if (hoursLeft >= 3) {
return exactTimeInAmPm.value;
}
if (hoursLeft > 0 || minutesLeft > 0) {
return hoursAndMinutesLeft.value;
}
return 'in some time';
});
const timeLeftToBackInOnline = computed(() => {
if (
hoursAndMinutesBackInOnline.value.hoursLeft >= 24 ||
(timeSlot.value.day !== currentDay.value && dayDiff.value === 0)
) {
const hourRelative = generateRelativeTime(
dayDiff.value + 1,
'days',
languageCode.value
);
return `${hourRelative}`;
}
if (
dayDiff.value >= 1 &&
presentHour.value >= currentDayTimings.value.closeHour
) {
return `on ${dayNameOfNextWorkingDay.value}`;
}
return hoursAndMinutesToBack.value;
});
function setTimeSlot() {
const workingTimeSlots = workingHours.value;
const currentSlot =
presentHour.value >= currentDayTimings.value.closeHour
? nextDayWorkingHours.value
: currentDayWorkingHours.value;
const slots = timeSlotParse(workingTimeSlots).length
? timeSlotParse(workingTimeSlots)
: defaultTimeSlot;
timeSlots.value = slots;
timeSlot.value = timeSlots.value.find(
slot => slot.day === currentSlot.day_of_week
);
}
return {
dayNames,
timeSlots,
timeSlot,
channelConfig,
workingHours,
newDateWithTimeZone,
presentHour,
presentMinute,
currentDay,
timeZoneValue,
languageCode,
currentDayWorkingHours,
nextDayWorkingHours,
currentDayTimings,
nextDayTimings,
dayDiff,
dayNameOfNextWorkingDay,
hoursAndMinutesBackInOnline,
exactTimeInAmPm,
hoursAndMinutesLeft,
hoursAndMinutesToBack,
timeLeftToBackInOnline,
setTimeSlot,
};
};
@@ -1,21 +0,0 @@
import { useRouter, useRoute } from 'dashboard/composables/route';
/**
* Composable for replacing the current route with a new one if it's different.
* @param {string} name - The name of the route to replace with.
* @param {Object} params - The params to pass to the new route.
* @returns {Function} A function that replaces the route when called.
*/
export const useReplaceRoute = () => {
const router = useRouter();
const route = useRoute();
return (name, params = {}) => {
if (route.name !== name) {
return router.replace({ name, params });
}
return undefined;
};
};
export default useReplaceRoute;
+1 -1
View File
@@ -26,7 +26,7 @@ export default {
if (workingHoursEnabled) {
return this.isOnline
? this.replyTimeStatus
: `${this.$t('REPLY_TIME.BACK_IN')} ${this.timeLeftToBackInOnline}`;
: `${this.$t('REPLY_TIME.BACK_IN')} ${this.timeLeftToBackInOnline}`; // timeLeftToBackInOnline should be pulled in from the useNextAvailabilityTime composable
}
return this.isOnline
? this.replyTimeStatus
@@ -0,0 +1,24 @@
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters({ darkMode: 'appConfig/darkMode' }),
prefersDarkMode() {
const isOSOnDarkMode =
this.darkMode === 'auto' &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
return isOSOnDarkMode || this.darkMode === 'dark';
},
},
methods: {
$dm(light, dark) {
if (this.darkMode === 'light') {
return light;
}
if (this.darkMode === 'dark') {
return dark;
}
return light + ' ' + dark;
},
},
};
@@ -0,0 +1,10 @@
export default {
methods: {
async replaceRoute(name, params = {}) {
if (this.$route.name !== name) {
return this.$router.replace({ name, params });
}
return undefined;
},
},
};
@@ -0,0 +1,41 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import darkModeMixin from '../darkModeMixin';
import Vuex from 'vuex';
const localVue = createLocalVue();
localVue.use(Vuex);
const darkModeValues = ['light', 'auto'];
describe('darkModeMixin', () => {
let getters;
let store;
beforeEach(() => {
getters = {
'appConfig/darkMode': () => darkModeValues[0],
};
store = new Vuex.Store({ getters });
});
it('if light theme', () => {
const Component = {
render() {},
mixins: [darkModeMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.$dm('bg-100', 'bg-600')).toBe('bg-100');
});
it('if auto theme', () => {
getters = {
'appConfig/darkMode': () => darkModeValues[2],
};
store = new Vuex.Store({ getters });
const Component = {
render() {},
mixins: [darkModeMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.$dm('bg-100', 'bg-600')).toBe('bg-100 bg-600');
});
});
+1 -8
View File
@@ -5,8 +5,6 @@ import ArticleCardSkeletonLoader from 'widget/components/ArticleCardSkeletonLoad
import { mapGetters } from 'vuex';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import useReplaceRoute from 'widget/composables/useReplaceRoute';
import { useDarkMode } from 'widget/composables/useDarkMode';
import routerMixin from 'widget/mixins/routerMixin';
import configMixin from 'widget/mixins/configMixin';
@@ -17,12 +15,7 @@ export default {
TeamAvailability,
ArticleCardSkeletonLoader,
},
mixins: [configMixin],
setup() {
const replaceRoute = useReplaceRoute();
const { prefersDarkMode } = useDarkMode();
return { prefersDarkMode, replaceRoute };
},
mixins: [configMixin, routerMixin, darkModeMixin],
computed: {
...mapGetters({
availableAgents: 'agent/availableAgents',
+2 -6
View File
@@ -1,7 +1,7 @@
<script>
import PreChatForm from '../components/PreChat/Form.vue';
import configMixin from '../mixins/configMixin';
import useReplaceRoute from 'widget/composables/useReplaceRoute';
import routerMixin from '../mixins/routerMixin';
import { isEmptyObject } from 'widget/helpers/utils';
import { ON_CONVERSATION_CREATED } from '../constants/widgetBusEvents';
@@ -9,11 +9,7 @@ export default {
components: {
PreChatForm,
},
mixins: [configMixin],
setup() {
const replaceRoute = useReplaceRoute();
return { replaceRoute };
},
mixins: [configMixin, routerMixin],
mounted() {
this.$emitter.on(ON_CONVERSATION_CREATED, () => {
// Redirect to messages page after conversation is created
+1 -1
View File
@@ -31,7 +31,7 @@
"dependencies": {
"@braid/vue-formulate": "^2.5.2",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.0.17",
"@chatwoot/prosemirror-schema": "1.0.16",
"@chatwoot/utils": "^0.0.25",
"@hcaptcha/vue-hcaptcha": "^0.3.2",
"@june-so/analytics-next": "^2.0.0",
+4 -4
View File
@@ -2914,10 +2914,10 @@
hotkeys-js "3.8.7"
lit "2.2.6"
"@chatwoot/prosemirror-schema@1.0.17":
version "1.0.17"
resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.17.tgz#5c04a94c8200b303d943369b2534730d5ba15a64"
integrity sha512-3hwBSMdb5a+Ilct0fNSlAvQu/9+pnyI/FoGshVRC9Hn1tCdZdS7LUlxOuDweta3HneVclhE+SxJN5lM33v5WGw==
"@chatwoot/prosemirror-schema@1.0.16":
version "1.0.16"
resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.16.tgz#63cfd4cff0f138466a95fedba5d237409a0b418e"
integrity sha512-qFPHPrfkG14vB0C/8+kF6huAvz4c2h6s07WKznZqUZ5CgJYd+fESQ49CScOYwOTO6luV4ghILv9kAVRjiaenOQ==
dependencies:
markdown-it-sup "^1.0.0"
prosemirror-commands "^1.1.4"