Compare commits

..
Author SHA1 Message Date
Sivin VargheseandGitHub 62fc4186ea Merge branch 'develop' into chore-replace-availability-mixin-with-composable-CW-3472 2024-08-28 16:48:46 +05:30
3b5f5b41ad chore: Replace campaign mixin with composable [CW-3463] (#9987)
# Pull Request Template

## Description

Repalces campaignMixin and its usage with the new useCampaign mixin

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

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-08-28 00:53:18 +05:30
Muhsin KelothandGitHub ea3c12d14f Merge branch 'develop' into chore-replace-availability-mixin-with-composable-CW-3472 2024-08-27 13:57:29 +05:30
Muhsin KelothandGitHub 427f5bef27 Merge branch 'develop' into chore-replace-availability-mixin-with-composable-CW-3472 2024-08-27 13:48:45 +05:30
Fayaz AhmedandGitHub d297987bc5 Merge branch 'develop' into chore-replace-availability-mixin-with-composable-CW-3472 2024-08-27 12:29:45 +05:30
Fayaz AhmedandGitHub 0aa5237bbf Merge branch 'develop' into chore-replace-availability-mixin-with-composable-CW-3472 2024-08-09 19:22:42 +05:30
Fayaz Ahmed 10282fd797 chore: Replace nextAvailibility mixing with the new useNextAvailability composable and test 2024-08-09 19:21:35 +05:30
Fayaz Ahmed f29a86d1f5 chore: integrate the composable replacing the mixing in pages and components 2024-08-09 13:10:21 +05:30
Fayaz Ahmed a795028979 chore: Replace availability mixin with useAvailability composable 2024-08-08 00:47:58 +05:30
18 changed files with 656 additions and 139 deletions
@@ -36,22 +36,6 @@ class DashboardAudioNotificationHelper {
initFaviconSwitcher();
};
updateInstanceValues = ({
alwaysPlayAudioAlert,
alertIfUnreadConversationExist,
audioAlertType,
audioAlertTone,
}) => {
this.audioAlertType = audioAlertType;
this.playAlertOnlyWhenHidden = !alwaysPlayAudioAlert;
this.alertIfUnreadConversationExist = alertIfUnreadConversationExist;
this.audioAlertTone = audioAlertTone;
getAlertAudio('', {
type: 'dashboard',
alertTone: this.audioAlertTone,
});
};
onAudioListenEvent = async () => {
try {
await getAlertAudio('', {
@@ -21,7 +21,7 @@ const initializeAudioAlerts = user => {
enable_audio_alerts: audioAlertType,
alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist,
notification_tone: audioAlertTone,
// UI Settings can be undefined initially as we don't send the
// UI Settings can be undefined initally as we don't send the
// entire payload for the user during the signup process.
} = uiSettings || {};
@@ -4,7 +4,7 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import campaignMixin from 'shared/mixins/campaignMixin';
import { useCampaign } from 'shared/composables/useCampaign';
import WootDateTimePicker from 'dashboard/components/ui/DateTimePicker.vue';
import { URLPattern } from 'urlpattern-polyfill';
import { CAMPAIGNS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
@@ -14,9 +14,9 @@ export default {
WootDateTimePicker,
WootMessageEditor,
},
mixins: [campaignMixin],
setup() {
return { v$: useVuelidate() };
const { campaignType, isOngoingType, isOneOffType } = useCampaign();
return { v$: useVuelidate(), campaignType, isOngoingType, isOneOffType };
},
data() {
return {
@@ -1,7 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import campaignMixin from 'shared/mixins/campaignMixin';
import { useCampaign } from 'shared/composables/useCampaign';
import CampaignsTable from './CampaignsTable.vue';
import EditCampaign from './EditCampaign.vue';
export default {
@@ -9,13 +9,16 @@ export default {
CampaignsTable,
EditCampaign,
},
mixins: [campaignMixin],
props: {
type: {
type: String,
default: '',
},
},
setup() {
const { campaignType } = useCampaign();
return { campaignType };
},
data() {
return {
showEditPopup: false,
@@ -1,7 +1,7 @@
<script>
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import campaignMixin from 'shared/mixins/campaignMixin';
import { useCampaign } from 'shared/composables/useCampaign';
import CampaignCard from './CampaignCard.vue';
export default {
@@ -10,9 +10,6 @@ export default {
Spinner,
CampaignCard,
},
mixins: [campaignMixin],
props: {
campaigns: {
type: Array,
@@ -27,7 +24,10 @@ export default {
default: false,
},
},
setup() {
const { isOngoingType } = useCampaign();
return { isOngoingType };
},
computed: {
currentInboxId() {
return this.$route.params.inboxId;
@@ -4,14 +4,13 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import campaignMixin from 'shared/mixins/campaignMixin';
import { useCampaign } from 'shared/composables/useCampaign';
import { URLPattern } from 'urlpattern-polyfill';
export default {
components: {
WootMessageEditor,
},
mixins: [campaignMixin],
props: {
selectedCampaign: {
type: Object,
@@ -19,7 +18,8 @@ export default {
},
},
setup() {
return { v$: useVuelidate() };
const { isOngoingType } = useCampaign();
return { v$: useVuelidate(), isOngoingType };
},
data() {
return {
@@ -1,5 +1,5 @@
<script>
import campaignMixin from 'shared/mixins/campaignMixin';
import { useCampaign } from 'shared/composables/useCampaign';
import Campaign from './Campaign.vue';
import AddCampaign from './AddCampaign.vue';
@@ -8,7 +8,10 @@ export default {
Campaign,
AddCampaign,
},
mixins: [campaignMixin],
setup() {
const { isOngoingType } = useCampaign();
return { isOngoingType };
},
data() {
return { showAddPopup: false };
},
@@ -4,7 +4,6 @@ import { useUISettings } from 'dashboard/composables/useUISettings';
import AudioAlertTone from './AudioAlertTone.vue';
import AudioAlertEvent from './AudioAlertEvent.vue';
import AudioAlertCondition from './AudioAlertCondition.vue';
import DashboardAudioNotificationHelper from 'dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper';
export default {
components: {
@@ -39,21 +38,6 @@ export default {
this.$store.dispatch('userNotificationSettings/get');
},
methods: {
updateAudioAlertInstanceValues() {
const {
enable_audio_alerts: audioAlert = '',
always_play_audio_alert: alwaysPlayAudioAlert,
alert_if_unread_assigned_conversation_exist:
alertIfUnreadConversationExist,
notification_tone: alertTone,
} = this.uiSettings;
DashboardAudioNotificationHelper.updateInstanceValues({
audioAlertType: audioAlert,
alwaysPlayAudioAlert: alwaysPlayAudioAlert,
alertIfUnreadConversationExist: alertIfUnreadConversationExist,
audioAlertTone: alertTone,
});
},
notificationUISettings(uiSettings) {
const {
enable_audio_alerts: audioAlert = '',
@@ -85,31 +69,27 @@ export default {
];
this.alertTone = alertTone || 'ding';
},
async handAudioAlertChange(value) {
handAudioAlertChange(value) {
this.audioAlert = value;
await this.updateUISettings({
this.updateUISettings({
enable_audio_alerts: this.audioAlert,
});
this.updateAudioAlertInstanceValues();
useAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
},
async handleAudioAlertConditions(id, value) {
handleAudioAlertConditions(id, value) {
if (id === 'tab_is_inactive') {
await this.updateUISettings({
this.updateUISettings({
always_play_audio_alert: !value,
});
this.updateAudioAlertInstanceValues();
} else if (id === 'conversations_are_read') {
await this.updateUISettings({
this.updateUISettings({
alert_if_unread_assigned_conversation_exist: value,
});
this.updateAudioAlertInstanceValues();
}
useAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
},
async handleAudioToneChange(value) {
await this.updateUISettings({ notification_tone: value });
this.updateAudioAlertInstanceValues();
handleAudioToneChange(value) {
this.updateUISettings({ notification_tone: value });
useAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
},
},
@@ -0,0 +1,47 @@
import { describe, it, expect, vi } from 'vitest';
import { useCampaign } from '../useCampaign';
import { useRoute } from 'dashboard/composables/route';
import { CAMPAIGN_TYPES } from '../../constants/campaign';
// Mock the useRoute composable
vi.mock('dashboard/composables/route', () => ({
useRoute: vi.fn(),
}));
describe('useCampaign', () => {
it('returns the correct campaign type for ongoing campaigns', () => {
useRoute.mockReturnValue({ name: 'ongoing_campaigns' });
const { campaignType } = useCampaign();
expect(campaignType.value).toBe(CAMPAIGN_TYPES.ONGOING);
});
it('returns the correct campaign type for one-off campaigns', () => {
useRoute.mockReturnValue({ name: 'one_off' });
const { campaignType } = useCampaign();
expect(campaignType.value).toBe(CAMPAIGN_TYPES.ONE_OFF);
});
it('isOngoingType returns true for ongoing campaigns', () => {
useRoute.mockReturnValue({ name: 'ongoing_campaigns' });
const { isOngoingType } = useCampaign();
expect(isOngoingType.value).toBe(true);
});
it('isOngoingType returns false for one-off campaigns', () => {
useRoute.mockReturnValue({ name: 'one_off' });
const { isOngoingType } = useCampaign();
expect(isOngoingType.value).toBe(false);
});
it('isOneOffType returns true for one-off campaigns', () => {
useRoute.mockReturnValue({ name: 'one_off' });
const { isOneOffType } = useCampaign();
expect(isOneOffType.value).toBe(true);
});
it('isOneOffType returns false for ongoing campaigns', () => {
useRoute.mockReturnValue({ name: 'ongoing_campaigns' });
const { isOneOffType } = useCampaign();
expect(isOneOffType.value).toBe(false);
});
});
@@ -0,0 +1,43 @@
import { computed } from 'vue';
import { useRoute } from 'dashboard/composables/route';
import { CAMPAIGN_TYPES } from '../constants/campaign';
/**
* Composable to manage campaign types.
*
* @returns {Object} - Computed properties for campaign type and its checks.
*/
export const useCampaign = () => {
const route = useRoute();
/**
* Computed property to determine the current campaign type based on the route name.
*/
const campaignType = computed(() => {
const campaignTypeMap = {
ongoing_campaigns: CAMPAIGN_TYPES.ONGOING,
one_off: CAMPAIGN_TYPES.ONE_OFF,
};
return campaignTypeMap[route.name];
});
/**
* Computed property to check if the current campaign type is 'ongoing'.
*/
const isOngoingType = computed(() => {
return campaignType.value === CAMPAIGN_TYPES.ONGOING;
});
/**
* Computed property to check if the current campaign type is 'one-off'.
*/
const isOneOffType = computed(() => {
return campaignType.value === CAMPAIGN_TYPES.ONE_OFF;
});
return {
campaignType,
isOngoingType,
isOneOffType,
};
};
@@ -1,19 +0,0 @@
import { CAMPAIGN_TYPES } from '../constants/campaign';
export default {
computed: {
campaignType() {
const campaignTypeMap = {
ongoing_campaigns: CAMPAIGN_TYPES.ONGOING,
one_off: CAMPAIGN_TYPES.ONE_OFF,
};
return campaignTypeMap[this.$route.name];
},
isOngoingType() {
return this.campaignType === CAMPAIGN_TYPES.ONGOING;
},
isOneOffType() {
return this.campaignType === CAMPAIGN_TYPES.ONE_OFF;
},
},
};
@@ -1,34 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import campaignMixin from '../campaignMixin';
const buildComponent = routeName => ({
template: '<div></div>',
computed: {
$route() {
return { name: routeName };
},
},
mixins: [campaignMixin],
});
describe('campaignMixin', () => {
beforeEach(() => {
global.window = Object.create(window);
});
it('returns the correct campaign type', () => {
const Component = buildComponent('one_off');
const wrapper = shallowMount(Component);
expect(wrapper.vm.campaignType).toBe('one_off');
});
it('isOneOffType returns true if path is one_off', () => {
const Component = buildComponent('one_off');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isOneOffType).toBe(true);
});
it('isOngoingType returns true if path is ongoing_campaigns', () => {
const Component = buildComponent('ongoing_campaigns');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isOngoingType).toBe(true);
});
});
+6 -2
View File
@@ -4,7 +4,7 @@ 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';
@@ -27,13 +27,17 @@ export default {
components: {
Spinner,
},
mixins: [availabilityMixin, configMixin, routerMixin, darkModeMixin],
mixins: [configMixin, routerMixin, darkModeMixin],
data() {
return {
isMobile: false,
campaignsSnoozedTill: undefined,
};
},
setup() {
const { isInBusinessHours } = useAvailability();
return { isInBusinessHours };
},
computed: {
...mapGetters({
activeCampaign: 'campaign/getActiveCampaign',
@@ -1,5 +1,5 @@
<script>
import availabilityMixin from 'widget/mixins/availability';
import { useAvailability } from 'widget/composables/useAvailability';
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import HeaderActions from './HeaderActions.vue';
@@ -12,7 +12,7 @@ export default {
FluentIcon,
HeaderActions,
},
mixins: [nextAvailabilityTime, availabilityMixin, routerMixin, darkMixin],
mixins: [nextAvailabilityTime, routerMixin, darkMixin],
props: {
avatarUrl: {
type: String,
@@ -35,16 +35,11 @@ export default {
default: () => {},
},
},
computed: {
isOnline() {
const { workingHoursEnabled } = this.channelConfig;
const anyAgentOnline = this.availableAgents.length > 0;
if (workingHoursEnabled) {
return this.isInBetweenTheWorkingHours;
}
return anyAgentOnline;
},
setup(props) {
const { isOnline, replyWaitMessage } = useAvailability(
props.availableAgents
);
return { isOnline, replyWaitMessage };
},
methods: {
onBackButtonClick() {
@@ -1,10 +1,9 @@
<script>
import { mapGetters } from 'vuex';
import { getContrastingTextColor } from '@chatwoot/utils';
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
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 +14,7 @@ export default {
AvailableAgents,
FluentIcon,
},
mixins: [configMixin, nextAvailabilityTime, availabilityMixin],
mixins: [configMixin],
props: {
availableAgents: {
type: Array,
@@ -26,7 +25,12 @@ export default {
default: false,
},
},
setup(props) {
const { isOnline, replyWaitMessage } = useAvailability(
props.availableAgents
);
return { isOnline, replyWaitMessage };
},
computed: {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
@@ -34,15 +38,6 @@ export default {
textColor() {
return getContrastingTextColor(this.widgetColor);
},
isOnline() {
const { workingHoursEnabled } = this.channelConfig;
const anyAgentOnline = this.availableAgents.length > 0;
if (workingHoursEnabled) {
return this.isInBetweenTheWorkingHours;
}
return anyAgentOnline;
},
},
methods: {
startConversation() {
@@ -0,0 +1,131 @@
import { computed } from 'vue';
import { utcToZonedTime } from 'date-fns-tz';
import { isTimeAfter } from 'shared/helpers/DateHelper';
import { useI18n } from 'dashboard/composables/useI18n';
import { useNextAvailability } from './useNextAvailability';
/**
* Composable for handling availability-related computations.
* @param {Object} t - Vue i18n instance for translations.
* @returns {Object} An object containing computed properties and methods for availability management.
*/
export function useAvailability(availableAgents) {
const { timeLeftToBackInOnline } = useNextAvailability();
const { t } = useI18n();
const channelConfig = computed(() => window.chatwootWebChannel);
const replyTime = computed(() => window.chatwootWebChannel.replyTime);
const replyTimeStatus = computed(() => {
switch (replyTime.value) {
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');
}
});
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 currentDayAvailability = computed(() => {
const { utcOffset } = channelConfig.value;
const dayOfTheWeek = getDateWithOffset(utcOffset).getDay();
const [workingHourConfig = {}] = channelConfig.value.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 isInBusinessHours = computed(() => {
const { workingHoursEnabled } = channelConfig.value;
return workingHoursEnabled ? isInBetweenTheWorkingHours.value : true;
});
const getDateWithOffset = utcOffset => {
return utcToZonedTime(new Date().toISOString(), utcOffset);
};
const isOnline = computed(() => {
if (availableAgents) {
const { workingHoursEnabled } = channelConfig.value;
const anyAgentOnline = availableAgents.length > 0;
if (workingHoursEnabled) {
return isInBetweenTheWorkingHours.value || anyAgentOnline;
}
}
return false;
});
return {
channelConfig,
replyTime,
replyTimeStatus,
replyWaitMessage,
outOfOfficeMessage,
currentDayAvailability,
isInBetweenTheWorkingHours,
isInBusinessHours,
isOnline,
getDateWithOffset,
};
}
@@ -0,0 +1,281 @@
import { ref, computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import {
timeSlotParse,
defaultTimeSlot,
} from 'dashboard/routes/dashboard/settings/inbox/helpers/businessHour.js';
import { utcToZonedTime } from 'date-fns-tz';
import { generateRelativeTime } from 'shared/helpers/DateHelper';
const MINUTE_ROUNDING_FACTOR = 5;
/**
* Composable for handling next availability calculations
* @returns {Object} An object containing computed properties and methods for next availability management
*/
export function useNextAvailability() {
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 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 currentDay = Object.keys(dayNames).find(
key => dayNames[key] === dayNames[day]
);
return Number(currentDay);
});
const timeZoneValue = computed(() => channelConfig.value.timezone);
const languageCode = computed(() => window.chatwootWebChannel.locale);
const currentDayWorkingHours = computed(() =>
workingHours.value.find(slot => slot.day_of_week === currentDay.value)
);
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]
);
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;
});
/**
* Gets the next day of the week
* @param {number} day - Current day of the week
* @returns {number} Next day of the week
*/
function getNextDay(day) {
return (day + 1) % 7;
}
/**
* Gets the next working hour for a given day
* @param {number} day - Day of the week
* @returns {Object|null} Working hour object for the next working day, or null if not found
*/
function getNextWorkingHour(day) {
const workingHour = workingHours.value.find(
slot => slot.day_of_week === day
);
if (workingHour && !workingHour.closed_all_day) {
return workingHour;
}
return null;
}
/**
* Calculates hours and minutes until next day open
* @param {number} openHour - Opening hour
* @param {number} openMinutes - Opening minutes
* @param {number} closeHour - Closing hour
* @returns {Object} Object containing hours and minutes left
*/
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 };
}
/**
* Sets the time slot
*/
function setTimeSlot() {
const timeSlots = workingHours.value;
const currentSlot =
presentHour.value >= currentDayTimings.value.closeHour
? nextDayWorkingHours.value
: currentDayWorkingHours.value;
const slots = timeSlotParse(timeSlots).length
? timeSlotParse(timeSlots)
: defaultTimeSlot;
timeSlots.value = slots;
timeSlot.value = timeSlots.value.find(
slot => slot.day === currentSlot.day_of_week
);
}
// Call setTimeSlot initially
setTimeSlot();
return {
dayNames,
timeSlots,
timeSlot,
channelConfig,
workingHours,
newDateWithTimeZone,
presentHour,
presentMinute,
currentDay,
timeZoneValue,
languageCode,
currentDayWorkingHours,
nextDayWorkingHours,
currentDayTimings,
nextDayTimings,
dayDiff,
dayNameOfNextWorkingDay,
hoursAndMinutesBackInOnline,
exactTimeInAmPm,
hoursAndMinutesLeft,
hoursAndMinutesToBack,
timeLeftToBackInOnline,
getNextDay,
getNextWorkingHour,
getHoursAndMinutesUntilNextDayOpen,
setTimeSlot,
};
}
@@ -0,0 +1,104 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ref } from 'vue';
import { useAvailability } from '../../composables/useAvailability';
vi.mock('dashboard/composables/useI18n', () => ({
useI18n: () => ({
t: key => key,
}),
}));
describe('useAvailability', () => {
beforeEach(() => {
vi.useFakeTimers();
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_minutes',
};
});
it('returns correct replyTimeStatus', () => {
const availableAgents = ref([]);
const { replyTimeStatus } = useAvailability(availableAgents);
expect(replyTimeStatus.value).toBe('REPLY_TIME.IN_A_FEW_MINUTES');
});
it('returns correct isInBetweenTheWorkingHours when in working hours', () => {
vi.setSystemTime(new Date('Wed Apr 13 2022 10:00:00 GMT-0700'));
const availableAgents = ref([]);
const { isInBetweenTheWorkingHours } = useAvailability(availableAgents);
expect(isInBetweenTheWorkingHours.value).toBe(true);
});
it('returns correct isInBetweenTheWorkingHours when outside working hours', () => {
vi.setSystemTime(new Date('Wed Apr 13 2022 18:00:00 GMT-0700'));
const availableAgents = ref([]);
const { isInBetweenTheWorkingHours } = useAvailability(availableAgents);
expect(isInBetweenTheWorkingHours.value).toBe(false);
});
it('returns true for isInBetweenTheWorkingHours when open all day', () => {
global.chatwootWebChannel.workingHours = [
{ day_of_week: 3, open_all_day: true },
];
const availableAgents = ref([]);
const { isInBetweenTheWorkingHours } = useAvailability(availableAgents);
expect(isInBetweenTheWorkingHours.value).toBe(true);
});
it('returns false for isInBetweenTheWorkingHours when closed all day', () => {
global.chatwootWebChannel.workingHours = [
{ day_of_week: 3, closed_all_day: true },
];
const availableAgents = ref([]);
const { isInBetweenTheWorkingHours } = useAvailability(availableAgents);
expect(isInBetweenTheWorkingHours.value).toBe(false);
});
it('returns correct isOnline status when agents are available', () => {
const availableAgents = ref([{ id: 1 }]);
const { isOnline } = useAvailability(availableAgents);
expect(isOnline.value).toBe(true);
});
it('returns correct isOnline status when no agents are available and outside working hours', () => {
vi.setSystemTime(new Date('Wed Apr 13 2022 18:00:00 GMT-0700'));
const availableAgents = ref([]);
const { isOnline } = useAvailability(availableAgents);
expect(isOnline.value).toBe(false);
});
it('returns correct replyWaitMessage when online', () => {
const availableAgents = ref([{ id: 1 }]);
const { replyWaitMessage } = useAvailability(availableAgents);
expect(replyWaitMessage.value).toBe('REPLY_TIME.IN_A_FEW_MINUTES');
});
it('returns correct replyWaitMessage when offline', () => {
vi.setSystemTime(new Date('Wed Apr 13 2022 18:00:00 GMT-0700'));
const availableAgents = ref([]);
const { replyWaitMessage } = useAvailability(availableAgents);
expect(replyWaitMessage.value).toBe('REPLY_TIME.BACK_IN at 08:30 AM');
});
});