From 6ca38e10e93f8fb1f499863c65c05d5c435d8c47 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 22 Aug 2025 00:43:34 +0530 Subject: [PATCH] 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 image ## 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 Co-authored-by: Pranav Co-authored-by: Shivam Mishra --- app/javascript/histoire.setup.ts | 25 +- app/javascript/widget/App.vue | 31 +- .../Availability/AvailabilityContainer.vue | 87 +++ .../Availability/AvailabilityText.story.vue | 217 +++++++ .../Availability/AvailabilityText.vue | 178 ++++++ .../widget/components/ChatFooter.vue | 9 +- .../widget/components/ChatHeader.vue | 76 +-- .../widget/components/PreChat/Form.vue | 3 +- .../widget/components/TeamAvailability.vue | 98 +-- .../composables/specs/useAvailability.spec.js | 125 ++++ .../widget/composables/useAvailability.js | 69 +++ .../widget/helpers/availabilityHelpers.js | 289 +++++++++ .../helpers/specs/availabilityHelpers.spec.js | 580 ++++++++++++++++++ app/javascript/widget/i18n/locale/en.json | 10 +- app/javascript/widget/mixins/availability.js | 100 --- .../widget/mixins/nextAvailabilityTime.js | 250 -------- app/javascript/widget/mixins/routerMixin.js | 10 - .../mixins/specs/availabilityMixin.spec.js | 87 --- .../mixins/specs/nextAvailabilityTime.spec.js | 402 ------------ app/javascript/widget/views/Home.vue | 12 +- app/javascript/widget/views/PreChatForm.vue | 10 +- 21 files changed, 1662 insertions(+), 1006 deletions(-) create mode 100644 app/javascript/widget/components/Availability/AvailabilityContainer.vue create mode 100644 app/javascript/widget/components/Availability/AvailabilityText.story.vue create mode 100644 app/javascript/widget/components/Availability/AvailabilityText.vue create mode 100644 app/javascript/widget/composables/specs/useAvailability.spec.js create mode 100644 app/javascript/widget/composables/useAvailability.js create mode 100644 app/javascript/widget/helpers/availabilityHelpers.js create mode 100644 app/javascript/widget/helpers/specs/availabilityHelpers.spec.js delete mode 100644 app/javascript/widget/mixins/availability.js delete mode 100644 app/javascript/widget/mixins/nextAvailabilityTime.js delete mode 100644 app/javascript/widget/mixins/routerMixin.js delete mode 100644 app/javascript/widget/mixins/specs/availabilityMixin.spec.js delete mode 100644 app/javascript/widget/mixins/specs/nextAvailabilityTime.spec.js diff --git a/app/javascript/histoire.setup.ts b/app/javascript/histoire.setup.ts index 7642da78d..1c80a9f85 100644 --- a/app/javascript/histoire.setup.ts +++ b/app/javascript/histoire.setup.ts @@ -1,6 +1,7 @@ import './design-system/histoire.scss'; import { defineSetupVue3 } from '@histoire/plugin-vue'; -import i18nMessages from 'dashboard/i18n'; +import dashboardI18n from 'dashboard/i18n'; +import widgetI18n from 'widget/i18n'; import { createI18n } from 'vue-i18n'; import { vResizeObserver } from '@vueuse/components'; import store from 'dashboard/store'; @@ -9,10 +10,30 @@ import VueDOMPurifyHTML from 'vue-dompurify-html'; import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js'; import { directive as onClickaway } from 'vue3-click-away'; +function mergeMessages(...sources) { + return sources.reduce((acc, src) => { + Object.keys(src).forEach(key => { + if ( + acc[key] && + typeof acc[key] === 'object' && + typeof src[key] === 'object' + ) { + acc[key] = mergeMessages(acc[key], src[key]); + } else { + acc[key] = src[key]; + } + }); + return acc; + }, {}); +} + const i18n = createI18n({ legacy: false, // https://github.com/intlify/vue-i18n/issues/1902 locale: 'en', - messages: i18nMessages, + messages: mergeMessages( + structuredClone(dashboardI18n), + structuredClone(widgetI18n) + ), }); export const setupVue3 = defineSetupVue3(({ app }) => { diff --git a/app/javascript/widget/App.vue b/app/javascript/widget/App.vue index 48409cef2..379b2bc53 100755 --- a/app/javascript/widget/App.vue +++ b/app/javascript/widget/App.vue @@ -4,12 +4,10 @@ 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 { getLocale } from './helpers/urlParamsHelper'; import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages'; import { isEmptyObject } from 'widget/helpers/utils'; import Spinner from 'shared/components/Spinner.vue'; -import routerMixin from './mixins/routerMixin'; import { getExtraSpaceToScroll, loadedEventConfig, @@ -20,6 +18,8 @@ import { ON_UNREAD_MESSAGE_CLICK, } from './constants/widgetBusEvents'; import { useDarkMode } from 'widget/composables/useDarkMode'; +import { useRouter } from 'vue-router'; +import { useAvailability } from 'widget/composables/useAvailability'; import { SDK_SET_BUBBLE_VISIBILITY } from '../shared/constants/sharedFrameEvents'; import { emitter } from 'shared/helpers/mitt'; @@ -28,10 +28,13 @@ export default { components: { Spinner, }, - mixins: [availabilityMixin, configMixin, routerMixin], + mixins: [configMixin], setup() { const { prefersDarkMode } = useDarkMode(); - return { prefersDarkMode }; + const router = useRouter(); + const { isInWorkingHours } = useAvailability(); + + return { prefersDarkMode, router, isInWorkingHours }; }, data() { return { @@ -157,15 +160,17 @@ export default { this.setUnreadView(); }); emitter.on(ON_UNREAD_MESSAGE_CLICK, () => { - this.replaceRoute('messages').then(() => this.unsetUnreadView()); + this.router + .replace({ name: 'messages' }) + .then(() => this.unsetUnreadView()); }); }, registerCampaignEvents() { emitter.on(ON_CAMPAIGN_MESSAGE_CLICK, () => { if (this.shouldShowPreChatForm) { - this.replaceRoute('prechat-form'); + this.router.replace({ name: 'prechat-form' }); } else { - this.replaceRoute('messages'); + this.router.replace({ name: 'messages' }); emitter.emit('execute-campaign', { campaignId: this.activeCampaign.id, }); @@ -176,7 +181,7 @@ export default { const { customAttributes, campaignId } = campaignDetails; const { websiteToken } = window.chatwootWebChannel; this.executeCampaign({ campaignId, websiteToken, customAttributes }); - this.replaceRoute('messages'); + this.router.replace({ name: 'messages' }); }); emitter.on('snooze-campaigns', () => { const expireBy = addHours(new Date(), 1); @@ -192,7 +197,7 @@ export default { !messageCount && !shouldSnoozeCampaign; if (this.isIFrame && isCampaignReadyToExecute) { - this.replaceRoute('campaigns').then(() => { + this.router.replace({ name: 'campaigns' }).then(() => { this.setIframeHeight(true); IFrameHelper.sendMessage({ event: 'setUnreadMode' }); }); @@ -207,7 +212,7 @@ export default { unreadMessageCount > 0 && !this.isWidgetOpen ) { - this.replaceRoute('unread-messages').then(() => { + this.router.replace({ name: 'unread-messages' }).then(() => { this.setIframeHeight(true); IFrameHelper.sendMessage({ event: 'setUnreadMode' }); }); @@ -263,7 +268,7 @@ export default { this.initCampaigns({ currentURL: referrerURL, websiteToken, - isInBusinessHours: this.isInBusinessHours, + isInBusinessHours: this.isInWorkingHours, }); window.referrerURL = referrerURL; this.setReferrerHost(referrerHost); @@ -314,12 +319,12 @@ export default { ['unread-messages', 'campaigns'].includes(this.$route.name); if (shouldShowMessageView) { - this.replaceRoute('messages'); + this.router.replace({ name: 'messages' }); } if (shouldShowHomeView) { this.$store.dispatch('conversation/setUserLastSeen'); this.unsetUnreadView(); - this.replaceRoute('home'); + this.router.replace({ name: 'home' }); } if (!message.isOpen) { this.resetCampaign(); diff --git a/app/javascript/widget/components/Availability/AvailabilityContainer.vue b/app/javascript/widget/components/Availability/AvailabilityContainer.vue new file mode 100644 index 000000000..367564751 --- /dev/null +++ b/app/javascript/widget/components/Availability/AvailabilityContainer.vue @@ -0,0 +1,87 @@ + + + diff --git a/app/javascript/widget/components/Availability/AvailabilityText.story.vue b/app/javascript/widget/components/Availability/AvailabilityText.story.vue new file mode 100644 index 000000000..aafcb3556 --- /dev/null +++ b/app/javascript/widget/components/Availability/AvailabilityText.story.vue @@ -0,0 +1,217 @@ + + + diff --git a/app/javascript/widget/components/Availability/AvailabilityText.vue b/app/javascript/widget/components/Availability/AvailabilityText.vue new file mode 100644 index 000000000..649224d20 --- /dev/null +++ b/app/javascript/widget/components/Availability/AvailabilityText.vue @@ -0,0 +1,178 @@ + + + diff --git a/app/javascript/widget/components/ChatFooter.vue b/app/javascript/widget/components/ChatFooter.vue index 2bc6ba7ec..c85727a2b 100755 --- a/app/javascript/widget/components/ChatFooter.vue +++ b/app/javascript/widget/components/ChatFooter.vue @@ -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, diff --git a/app/javascript/widget/components/ChatHeader.vue b/app/javascript/widget/components/ChatHeader.vue index 7c497b70f..578fb984e 100644 --- a/app/javascript/widget/components/ChatHeader.vue +++ b/app/javascript/widget/components/ChatHeader.vue @@ -1,55 +1,26 @@ - @@ -79,9 +50,12 @@ export default { ${isOnline ? 'bg-n-teal-10' : 'hidden'}`" /> -
- {{ replyWaitMessage }} -
+ diff --git a/app/javascript/widget/components/PreChat/Form.vue b/app/javascript/widget/components/PreChat/Form.vue index 6fe8e45a1..84f0036fd 100644 --- a/app/javascript/widget/components/PreChat/Form.vue +++ b/app/javascript/widget/components/PreChat/Form.vue @@ -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, diff --git a/app/javascript/widget/components/TeamAvailability.vue b/app/javascript/widget/components/TeamAvailability.vue index 866854c09..cc08d34c2 100644 --- a/app/javascript/widget/components/TeamAvailability.vue +++ b/app/javascript/widget/components/TeamAvailability.vue @@ -1,74 +1,27 @@ - @@ -76,17 +29,8 @@ export default {
-
-
-
- {{ headerMessage }} -
-
- {{ replyWaitMessage }} -
-
- -
+ +