feat: Migrate availability mixins to composable and helper (#11596)

# Pull Request Template

## Description

**This PR includes:**

* Refactored two legacy mixins (`availability.js`,
`nextAvailability.js`) into a Vue 3 composable (`useAvailability`),
helper module and component based rendering logic.
* Fixed an issue where the widget wouldn't load if business hours were
enabled but all days were unchecked.
* Fixed translation issue
[[#11280](https://github.com/chatwoot/chatwoot/issues/11280)](https://github.com/chatwoot/chatwoot/issues/11280).
* Reduced code complexity and size.
* Added test coverage for both the composable and helper functions.

## Type of change

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

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/2bc3ed694b4349419505e275d14d0b98?sid=22d585e4-0dc7-4242-bcb6-e3edc16e3aee

### Story
<img width="995" height="442" alt="image"
src="https://github.com/user-attachments/assets/d6340738-07db-41d5-86fa-a8ecf734cc70"
/>



## Checklist:

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


Fixes https://github.com/chatwoot/chatwoot/issues/12012

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
Sivin Varghese
2025-08-22 00:43:34 +05:30
committed by GitHub
parent 1a1dfd09cb
commit 6ca38e10e9
21 changed files with 1662 additions and 1006 deletions

View File

@@ -0,0 +1,87 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import GroupedAvatars from 'widget/components/GroupedAvatars.vue';
import AvailabilityText from './AvailabilityText.vue';
import { useAvailability } from 'widget/composables/useAvailability';
const props = defineProps({
agents: {
type: Array,
default: () => [],
},
showHeader: {
type: Boolean,
default: true,
},
showAvatars: {
type: Boolean,
default: true,
},
textClasses: {
type: String,
default: '',
},
});
const { t } = useI18n();
const availableMessage = useMapGetter('appConfig/getAvailableMessage');
const unavailableMessage = useMapGetter('appConfig/getUnavailableMessage');
const {
currentTime,
hasOnlineAgents,
isOnline,
inboxConfig,
isInWorkingHours,
} = useAvailability(props.agents);
const workingHours = computed(() => inboxConfig.value.workingHours || []);
const workingHoursEnabled = computed(
() => inboxConfig.value.workingHoursEnabled || false
);
const utcOffset = computed(
() => inboxConfig.value.utcOffset || inboxConfig.value.timezone || 'UTC'
);
const replyTime = computed(
() => inboxConfig.value.replyTime || 'in_a_few_minutes'
);
// If online or in working hours
const isAvailable = computed(
() => isOnline.value || (workingHoursEnabled.value && isInWorkingHours.value)
);
const headerText = computed(() =>
isAvailable.value
? availableMessage.value || t('TEAM_AVAILABILITY.ONLINE')
: unavailableMessage.value || t('TEAM_AVAILABILITY.OFFLINE')
);
</script>
<template>
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col gap-1">
<div v-if="showHeader" class="font-medium text-n-slate-12">
{{ headerText }}
</div>
<AvailabilityText
:time="currentTime"
:utc-offset="utcOffset"
:working-hours="workingHours"
:working-hours-enabled="workingHoursEnabled"
:has-online-agents="hasOnlineAgents"
:reply-time="replyTime"
:is-online="isOnline"
:is-in-working-hours="isInWorkingHours"
:class="textClasses"
class="text-n-slate-11"
/>
</div>
<GroupedAvatars v-if="showAvatars && isOnline" :users="agents" />
</div>
</template>

View File

@@ -0,0 +1,217 @@
<script setup>
import AvailabilityText from './AvailabilityText.vue';
// Base time for consistent testing: Monday, July 15, 2024, 10:00:00 UTC
const baseTime = new Date('2024-07-15T10:00:00.000Z');
const utcOffset = '+00:00'; // UTC
const defaultProps = {
time: baseTime,
utcOffset,
workingHours: [
{
dayOfWeek: 0,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: false,
}, // Sunday
{
dayOfWeek: 1,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: false,
}, // Monday (current day)
{
dayOfWeek: 2,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: false,
}, // Tuesday
{
dayOfWeek: 3,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: false,
}, // Wednesday
{
dayOfWeek: 4,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: false,
}, // Thursday
{
dayOfWeek: 5,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: false,
}, // Friday
{
dayOfWeek: 6,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: true,
}, // Saturday (closed)
],
workingHoursEnabled: true,
replyTime: 'in_a_few_minutes',
isOnline: true,
isInWorkingHours: true,
};
const createVariant = (
title,
propsOverride = {},
isOnlineOverride = null,
isInWorkingHoursOverride = null
) => {
const props = { ...defaultProps, ...propsOverride };
if (isOnlineOverride !== null) props.isOnline = isOnlineOverride;
if (isInWorkingHoursOverride !== null)
props.isInWorkingHours = isInWorkingHoursOverride;
// Adjust time for specific scenarios
if (title.includes('Back Tomorrow')) {
// Set time to just after closing on Monday to trigger 'Back Tomorrow' (Tuesday)
props.time = new Date('2024-07-15T17:01:00.000Z');
props.isInWorkingHours = false;
props.isOnline = false;
}
if (title.includes('Back Multiple Days Away')) {
// Set time to Friday evening to trigger 'Back on Sunday' (as Saturday is closed)
props.time = new Date('2024-07-19T18:00:00.000Z');
props.isInWorkingHours = false;
props.isOnline = false;
}
if (title.includes('Back Same Day - In Minutes')) {
// Monday 16:50, next slot is 17:00 (in 10 minutes)
// To make this specific, let's assume the next slot is within the hour
// For this, we need to be outside working hours but a slot is available soon.
// Let's say current time is 8:50 AM, office opens at 9:00 AM.
props.time = new Date('2024-07-15T08:50:00.000Z');
props.isInWorkingHours = false;
props.isOnline = false;
}
if (title.includes('Back Same Day - In Hours')) {
// Monday 07:30 AM, office opens at 9:00 AM (in 1.5 hours, rounds to 2 hours)
props.time = new Date('2024-07-15T07:30:00.000Z');
props.isInWorkingHours = false;
props.isOnline = false;
}
if (title.includes('in 1 hour')) {
// Monday 08:00 AM, office opens at 9:00 AM (exactly in 1 hour)
// At exactly 1 hour difference, remainingMinutes = 0
props.time = new Date('2024-07-15T08:00:00.000Z');
props.isInWorkingHours = false;
props.isOnline = false;
}
if (title.includes('Back Same Day - At Time')) {
// Monday 05:00 AM, office opens at 9:00 AM (at 9:00 AM)
props.time = new Date('2024-07-15T05:00:00.000Z');
props.isInWorkingHours = false;
props.isOnline = false;
}
return {
title,
props,
};
};
const variants = [
createVariant(
'Working Hours Disabled - Online',
{ workingHoursEnabled: false },
true,
true
),
createVariant(
'Working Hours Disabled - Offline',
{ workingHoursEnabled: false },
false,
false
),
createVariant(
'All Day Closed - Offline',
{
workingHours: defaultProps.workingHours.map(wh => ({
...wh,
closedAllDay: true,
})),
},
false,
false
),
createVariant('Online and In Working Hours', {}, true, true),
createVariant(
'No Next Slot Available (e.g., all future slots closed or empty workingHours)',
{ workingHours: [] }, // No working hours defined
false,
false
),
createVariant(
'Back Tomorrow',
{},
false,
false // Time will be adjusted by createVariant
),
createVariant('Back Multiple Days Away (e.g., on Sunday)', {}, false, false),
createVariant(
'Back Same Day - In Minutes (e.g., in 10 minutes)',
{},
false,
false
),
createVariant(
'Back Same Day - In Hours (e.g., in 2 hours)',
{},
false,
false
),
createVariant(
'Back Same Day - Exactly an Hour (e.g., in 1 hour)',
{},
false,
false
),
createVariant(
'Back Same Day - At Time (e.g., at 09:00 AM)',
{},
false,
false
),
];
</script>
<template>
<Story
title="Widget/Components/Availability/AvailabilityText"
:layout="{ type: 'grid', width: 300 }"
>
<Variant v-for="(variant, i) in variants" :key="i" :title="variant.title">
<AvailabilityText
:time="variant.props.time"
:utc-offset="variant.props.utcOffset"
:working-hours="variant.props.workingHours"
:working-hours-enabled="variant.props.workingHoursEnabled"
:reply-time="variant.props.replyTime"
:is-online="variant.props.isOnline"
:is-in-working-hours="variant.props.isInWorkingHours"
class="text-n-slate-11"
/>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,178 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { getTime } from 'dashboard/routes/dashboard/settings/inbox/helpers/businessHour.js';
import { findNextAvailableSlotDetails } from 'widget/helpers/availabilityHelpers';
const props = defineProps({
time: {
type: Date,
required: true,
},
utcOffset: {
type: String,
required: true,
},
workingHours: {
type: Array,
required: true,
},
workingHoursEnabled: {
type: Boolean,
required: true,
},
replyTime: {
type: String,
default: 'in_a_few_minutes',
},
isOnline: {
type: Boolean,
required: true,
},
isInWorkingHours: {
type: Boolean,
required: true,
},
});
const MINUTE_ROUNDING_INTERVAL = 5;
const HOUR_THRESHOLD_FOR_EXACT_TIME = 3;
const MINUTES_IN_HOUR = 60;
const { t } = useI18n();
const dayNames = computed(() => [
t('DAY_NAMES.SUNDAY'),
t('DAY_NAMES.MONDAY'),
t('DAY_NAMES.TUESDAY'),
t('DAY_NAMES.WEDNESDAY'),
t('DAY_NAMES.THURSDAY'),
t('DAY_NAMES.FRIDAY'),
t('DAY_NAMES.SATURDAY'),
]);
// Check if all days in working hours are closed
const allDayClosed = computed(() => {
if (!props.workingHours.length) return false;
return props.workingHours.every(slot => slot.closedAllDay);
});
const replyTimeMessage = computed(() => {
const replyTimeKey = `REPLY_TIME.${props.replyTime.toUpperCase()}`;
return t(replyTimeKey);
});
const nextSlot = computed(() => {
if (
!props.workingHoursEnabled ||
allDayClosed.value ||
(props.isInWorkingHours && props.isOnline)
) {
return null;
}
const slot = findNextAvailableSlotDetails(
props.time,
props.utcOffset,
props.workingHours
);
if (!slot) return null;
return {
...slot,
hoursUntilOpen: Math.floor(slot.minutesUntilOpen / MINUTES_IN_HOUR),
remainingMinutes: slot.minutesUntilOpen % MINUTES_IN_HOUR,
};
});
const roundedMinutesUntilOpen = computed(() => {
if (!nextSlot.value) return 0;
return (
Math.ceil(nextSlot.value.remainingMinutes / MINUTE_ROUNDING_INTERVAL) *
MINUTE_ROUNDING_INTERVAL
);
});
const adjustedHoursUntilOpen = computed(() => {
if (!nextSlot.value) return 0;
return nextSlot.value.remainingMinutes > 0
? nextSlot.value.hoursUntilOpen + 1
: nextSlot.value.hoursUntilOpen;
});
const formattedOpeningTime = computed(() => {
if (!nextSlot.value) return '';
return getTime(
nextSlot.value.config.openHour || 0,
nextSlot.value.config.openMinutes || 0
);
});
</script>
<template>
<span>
<!-- 1. If currently in working hours, show reply time -->
<template v-if="isInWorkingHours">
{{ replyTimeMessage }}
</template>
<!-- 2. Else, if working hours are disabled, show based on online status -->
<template v-else-if="!workingHoursEnabled">
{{
isOnline
? replyTimeMessage
: t('TEAM_AVAILABILITY.BACK_AS_SOON_AS_POSSIBLE')
}}
</template>
<!-- 3. Else (not in working hours, but working hours ARE enabled) -->
<!-- Check if all configured slots are 'closedAllDay' -->
<template v-else-if="allDayClosed">
{{ t('TEAM_AVAILABILITY.BACK_AS_SOON_AS_POSSIBLE') }}
</template>
<!-- 4. Else (not in WH, WH enabled, not allDayClosed), calculate next slot -->
<template v-else-if="!nextSlot">
{{ t('REPLY_TIME.BACK_IN_SOME_TIME') }}
</template>
<!-- Tomorrow -->
<template v-else-if="nextSlot.daysUntilOpen === 1">
{{ t('REPLY_TIME.BACK_TOMORROW') }}
</template>
<!-- Multiple days away (eg: on Monday) -->
<template v-else-if="nextSlot.daysUntilOpen > 1">
{{
t('REPLY_TIME.BACK_ON_DAY', {
day: dayNames[nextSlot.config.dayOfWeek],
})
}}
</template>
<!-- Same day - less than 1 hour (eg: in 5 minutes) -->
<template v-else-if="nextSlot.hoursUntilOpen === 0">
{{
t('REPLY_TIME.BACK_IN_MINUTES', {
time: `${roundedMinutesUntilOpen}`,
})
}}
</template>
<!-- Same day - less than 3 hours (eg: in 2 hours) -->
<template
v-else-if="nextSlot.hoursUntilOpen < HOUR_THRESHOLD_FOR_EXACT_TIME"
>
{{ t('REPLY_TIME.BACK_IN_HOURS', adjustedHoursUntilOpen) }}
</template>
<!-- Same day - 3+ hours away (eg: at 10:00 AM) -->
<template v-else>
{{
t('REPLY_TIME.BACK_AT_TIME', {
time: formattedOpeningTime,
})
}}
</template>
</span>
</template>

View File

@@ -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 routerMixin from 'widget/mixins/routerMixin';
import { useRouter } from 'vue-router';
import { IFrameHelper } from '../helpers/utils';
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
import { emitter } from 'shared/helpers/mitt';
@@ -17,7 +17,10 @@ export default {
CustomButton,
FooterReplyTo,
},
mixins: [routerMixin],
setup() {
const router = useRouter();
return { router };
},
data() {
return {
inReplyTo: null,
@@ -77,7 +80,7 @@ export default {
this.inReplyTo = null;
},
startNewConversation() {
this.replaceRoute('prechat-form');
this.router.replace({ name: 'prechat-form' });
IFrameHelper.sendMessage({
event: 'onEvent',
eventIdentifier: CHATWOOT_ON_START_CONVERSATION,

View File

@@ -1,55 +1,26 @@
<script>
import availabilityMixin from 'widget/mixins/availability';
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
<script setup>
import { toRef } from 'vue';
import { useRouter } from 'vue-router';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import HeaderActions from './HeaderActions.vue';
import routerMixin from 'widget/mixins/routerMixin';
import AvailabilityContainer from 'widget/components/Availability/AvailabilityContainer.vue';
import { useAvailability } from 'widget/composables/useAvailability';
export default {
name: 'ChatHeader',
components: {
FluentIcon,
HeaderActions,
},
mixins: [nextAvailabilityTime, availabilityMixin, routerMixin],
props: {
avatarUrl: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
showPopoutButton: {
type: Boolean,
default: false,
},
showBackButton: {
type: Boolean,
default: false,
},
availableAgents: {
type: Array,
default: () => {},
},
},
computed: {
isOnline() {
const { workingHoursEnabled } = this.channelConfig;
const anyAgentOnline = this.availableAgents.length > 0;
const props = defineProps({
avatarUrl: { type: String, default: '' },
title: { type: String, default: '' },
showPopoutButton: { type: Boolean, default: false },
showBackButton: { type: Boolean, default: false },
availableAgents: { type: Array, default: () => [] },
});
if (workingHoursEnabled) {
return this.isInBetweenTheWorkingHours;
}
return anyAgentOnline;
},
},
methods: {
onBackButtonClick() {
this.replaceRoute('home');
},
},
const availableAgents = toRef(props, 'availableAgents');
const router = useRouter();
const { isOnline } = useAvailability(availableAgents);
const onBackButtonClick = () => {
router.replace({ name: 'home' });
};
</script>
@@ -79,9 +50,12 @@ export default {
${isOnline ? 'bg-n-teal-10' : 'hidden'}`"
/>
</div>
<div class="text-xs leading-3 text-n-slate-11">
{{ replyWaitMessage }}
</div>
<AvailabilityContainer
:agents="availableAgents"
:show-header="false"
:show-avatars="false"
text-classes="text-xs leading-3"
/>
</div>
</div>
<HeaderActions :show-popout-button="showPopoutButton" />

View File

@@ -6,7 +6,6 @@ import { getContrastingTextColor } from '@chatwoot/utils';
import { isEmptyObject } from 'widget/helpers/utils';
import { getRegexp } from 'shared/helpers/Validators';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import routerMixin from 'widget/mixins/routerMixin';
import configMixin from 'widget/mixins/configMixin';
import { FormKit, createInput } from '@formkit/vue';
import PhoneInput from 'widget/components/Form/PhoneInput.vue';
@@ -17,7 +16,7 @@ export default {
Spinner,
FormKit,
},
mixins: [routerMixin, configMixin],
mixins: [configMixin],
props: {
options: {
type: Object,

View File

@@ -1,74 +1,27 @@
<script>
import { mapGetters } from 'vuex';
import { getContrastingTextColor } from '@chatwoot/utils';
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
import configMixin from 'widget/mixins/configMixin';
import availabilityMixin from 'widget/mixins/availability';
<script setup>
import { IFrameHelper } from 'widget/helpers/utils';
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
import GroupedAvatars from 'widget/components/GroupedAvatars.vue';
import AvailabilityContainer from 'widget/components/Availability/AvailabilityContainer.vue';
import { useMapGetter } from 'dashboard/composables/store.js';
export default {
name: 'TeamAvailability',
components: {
GroupedAvatars,
},
mixins: [configMixin, nextAvailabilityTime, availabilityMixin],
props: {
availableAgents: {
type: Array,
default: () => {},
},
hasConversation: {
type: Boolean,
default: false,
},
},
emits: ['startConversation'],
const props = defineProps({
availableAgents: { type: Array, default: () => [] },
hasConversation: { type: Boolean, default: false },
});
computed: {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
availableMessage: 'appConfig/getAvailableMessage',
unavailableMessage: 'appConfig/getUnavailableMessage',
}),
textColor() {
return getContrastingTextColor(this.widgetColor);
},
agentAvatars() {
return this.availableAgents.map(agent => ({
name: agent.name,
avatar: agent.avatar_url,
id: agent.id,
}));
},
headerMessage() {
return this.isOnline
? this.availableMessage || this.$t('TEAM_AVAILABILITY.ONLINE')
: this.unavailableMessage || this.$t('TEAM_AVAILABILITY.OFFLINE');
},
isOnline() {
const { workingHoursEnabled } = this.channelConfig;
const anyAgentOnline = this.availableAgents.length > 0;
const emit = defineEmits(['startConversation']);
if (workingHoursEnabled) {
return this.isInBetweenTheWorkingHours;
}
return anyAgentOnline;
},
},
methods: {
startConversation() {
this.$emit('startConversation');
if (!this.hasConversation) {
IFrameHelper.sendMessage({
event: 'onEvent',
eventIdentifier: CHATWOOT_ON_START_CONVERSATION,
data: { hasConversation: false },
});
}
},
},
const widgetColor = useMapGetter('appConfig/getWidgetColor');
const startConversation = () => {
emit('startConversation');
if (!props.hasConversation) {
IFrameHelper.sendMessage({
event: 'onEvent',
eventIdentifier: CHATWOOT_ON_START_CONVERSATION,
data: { hasConversation: false },
});
}
};
</script>
@@ -76,17 +29,8 @@ export default {
<div
class="flex flex-col gap-3 w-full shadow outline-1 outline outline-n-container rounded-xl bg-n-background dark:bg-n-solid-2 px-5 py-4"
>
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col gap-1">
<div class="font-medium text-n-slate-12 line-clamp-2">
{{ headerMessage }}
</div>
<div class="text-n-slate-11">
{{ replyWaitMessage }}
</div>
</div>
<GroupedAvatars v-if="isOnline" :users="availableAgents" />
</div>
<AvailabilityContainer :agents="availableAgents" show-header show-avatars />
<button
class="inline-flex items-center gap-1 font-medium text-n-slate-12"
:style="{ color: widgetColor }"