Merge branch 'develop' into feat/CW-5624

This commit is contained in:
Sivin Varghese
2025-10-13 11:50:25 +05:30
committed by GitHub
38 changed files with 773 additions and 30 deletions
@@ -1,9 +1,11 @@
<script setup>
import { computed, onMounted } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { format } from 'date-fns';
import sessionStorage from 'shared/helpers/sessionStorage';
import BillingMeter from './components/BillingMeter.vue';
import BillingCard from './components/BillingCard.vue';
@@ -13,7 +15,8 @@ import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
const { currentAccount } = useAccount();
const router = useRouter();
const { currentAccount, isOnChatwootCloud } = useAccount();
const {
captainEnabled,
captainLimits,
@@ -24,6 +27,12 @@ const {
const uiFlags = useMapGetter('accounts/getUIFlags');
const store = useStore();
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
// State for handling refresh attempts and loading
const isWaitingForBilling = ref(false);
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
});
@@ -61,11 +70,45 @@ const hasABillingPlan = computed(() => {
const fetchAccountDetails = async () => {
if (!hasABillingPlan.value) {
store.dispatch('accounts/subscription');
await store.dispatch('accounts/subscription');
fetchLimits();
}
};
const handleBillingPageLogic = async () => {
// If self-hosted, redirect to dashboard
if (!isOnChatwootCloud.value) {
router.push({ name: 'home' });
return;
}
// Check if we've already attempted a refresh for billing setup
const billingRefreshAttempted = sessionStorage.get(BILLING_REFRESH_ATTEMPTED);
// If cloud user, fetch account details first
await fetchAccountDetails();
// If still no billing plan after fetch
if (!hasABillingPlan.value) {
// If we haven't attempted refresh yet, do it once
if (!billingRefreshAttempted) {
isWaitingForBilling.value = true;
sessionStorage.set(BILLING_REFRESH_ATTEMPTED, true);
setTimeout(() => {
window.location.reload();
}, 5000);
} else {
// We've already tried refreshing, so just show the no billing message
// Clear the flag for future visits
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
}
} else {
// Billing plan found, clear any existing refresh flag
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
}
};
const onClickBillingPortal = () => {
store.dispatch('accounts/checkout');
};
@@ -76,14 +119,18 @@ const onToggleChatWindow = () => {
}
};
onMounted(fetchAccountDetails);
onMounted(handleBillingPageLogic);
</script>
<template>
<SettingsLayout
:is-loading="uiFlags.isFetchingItem"
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
:no-records-found="!hasABillingPlan"
:is-loading="uiFlags.isFetchingItem || isWaitingForBilling"
:loading-message="
isWaitingForBilling
? $t('BILLING_SETTINGS.NO_BILLING_USER')
: $t('ATTRIBUTES_MGMT.LOADING')
"
:no-records-found="!hasABillingPlan && !isWaitingForBilling"
:no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')"
>
<template #header>
@@ -96,11 +96,12 @@ export default {
return parse(this.toTime, 'hh:mm a', new Date());
},
totalHours() {
if (this.timeSlot.openAllDay) {
return 24;
}
const totalHours = differenceInMinutes(this.toDate, this.fromDate) / 60;
return totalHours;
if (this.timeSlot.openAllDay) return '24h';
const totalMinutes = differenceInMinutes(this.toDate, this.fromDate);
const [h, m] = [Math.floor(totalMinutes / 60), totalMinutes % 60];
return [h && `${h}h`, m && `${m}m`].filter(Boolean).join(' ') || '0m';
},
hasError() {
return !this.timeSlot.valid;
@@ -211,7 +212,7 @@ export default {
v-if="isDayEnabled && !hasError"
class="label bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text text-xs inline-block px-2 py-1 rounded-lg cursor-default whitespace-nowrap"
>
{{ totalHours }} {{ $t('INBOX_MGMT.BUSINESS_HOURS.DAY.HOURS') }}
{{ totalHours }}
</span>
</div>
</div>
@@ -53,6 +53,7 @@ export const generateTimeSlots = (step = 15) => {
Generates a list of time strings from 12:00 AM to next 24 hours. Each new string
will be generated by adding `step` minutes to the previous one.
The list is generated by starting with a random day and adding step minutes till end of the same day.
Always includes 11:59 PM as the final slot to complete the day.
*/
const date = new Date(1970, 1, 1);
const slots = [];
@@ -66,6 +67,13 @@ export const generateTimeSlots = (step = 15) => {
);
date.setMinutes(date.getMinutes() + step);
}
// Always add 11:59 PM as the final slot if it's not already included
const lastSlot = '11:59 PM';
if (!slots.includes(lastSlot)) {
slots.push(lastSlot);
}
return slots;
};
@@ -7,10 +7,19 @@ import {
} from '../businessHour';
describe('#generateTimeSlots', () => {
it('returns correct number of time slots', () => {
expect(generateTimeSlots(15).length).toStrictEqual((60 / 15) * 24);
it('returns correct number of time slots for 15-minute intervals', () => {
const slots = generateTimeSlots(15);
// 24 hours * 4 slots per hour + 1 for 11:59 PM = 97 slots
expect(slots.length).toStrictEqual(97);
});
it('returns correct time slots', () => {
it('returns correct number of time slots for 30-minute intervals', () => {
const slots = generateTimeSlots(30);
// 24 hours * 2 slots per hour + 1 for 11:59 PM = 49 slots
expect(slots.length).toStrictEqual(49);
});
it('returns correct time slots for 4-hour intervals', () => {
expect(generateTimeSlots(240)).toStrictEqual([
'12:00 AM',
'04:00 AM',
@@ -18,8 +27,51 @@ describe('#generateTimeSlots', () => {
'12:00 PM',
'04:00 PM',
'08:00 PM',
'11:59 PM',
]);
});
it('always starts with 12:00 AM', () => {
expect(generateTimeSlots(15)[0]).toStrictEqual('12:00 AM');
expect(generateTimeSlots(30)[0]).toStrictEqual('12:00 AM');
expect(generateTimeSlots(60)[0]).toStrictEqual('12:00 AM');
});
it('always ends with 11:59 PM', () => {
const slots15 = generateTimeSlots(15);
const slots30 = generateTimeSlots(30);
const slots60 = generateTimeSlots(60);
expect(slots15[slots15.length - 1]).toStrictEqual('11:59 PM');
expect(slots30[slots30.length - 1]).toStrictEqual('11:59 PM');
expect(slots60[slots60.length - 1]).toStrictEqual('11:59 PM');
});
it('includes 11:59 PM even when it would not be in regular intervals', () => {
const slots = generateTimeSlots(30);
expect(slots).toContain('11:59 PM');
expect(slots).toContain('11:30 PM'); // Regular interval
});
it('does not duplicate 11:59 PM if it already exists in regular intervals', () => {
// Test with a step that would naturally include 11:59 PM
const slots = generateTimeSlots(1); // 1-minute intervals
const count11_59 = slots.filter(slot => slot === '11:59 PM').length;
expect(count11_59).toStrictEqual(1);
});
it('generates correct time format', () => {
const slots = generateTimeSlots(60);
expect(slots).toContain('01:00 AM');
expect(slots).toContain('12:00 PM');
expect(slots).toContain('01:00 PM');
expect(slots).toContain('11:00 PM');
});
it('handles edge case with very large step', () => {
const slots = generateTimeSlots(1440); // 24 hours
expect(slots).toStrictEqual(['12:00 AM', '11:59 PM']);
});
});
describe('#getTime', () => {
@@ -154,7 +154,10 @@ const equalTo = (filterValue, conversationValue) => {
* It only works with string values and returns false for non-string types.
*/
const contains = (filterValue, conversationValue) => {
if (typeof conversationValue === 'string') {
if (
typeof conversationValue === 'string' &&
typeof filterValue === 'string'
) {
return conversationValue.toLowerCase().includes(filterValue.toLowerCase());
}
return false;
@@ -190,10 +193,8 @@ const compareDates = (conversationValue, filterValue, compareFn) => {
const matchesCondition = (conversationValue, filter) => {
const { filter_operator: filterOperator, values } = filter;
// Handle null/undefined values
if (conversationValue === null || conversationValue === undefined) {
return filterOperator === 'is_not_present';
}
const isNullish =
conversationValue === null || conversationValue === undefined;
const filterValue = Array.isArray(values)
? values.map(resolveValue)
@@ -213,10 +214,10 @@ const matchesCondition = (conversationValue, filter) => {
return !contains(filterValue, conversationValue);
case 'is_present':
return true; // We already handled null/undefined above
return !isNullish;
case 'is_not_present':
return false; // We already handled null/undefined above
return isNullish;
case 'is_greater_than':
return compareDates(conversationValue, filterValue, (a, b) => a > b);
@@ -225,6 +226,10 @@ const matchesCondition = (conversationValue, filter) => {
return compareDates(conversationValue, filterValue, (a, b) => a < b);
case 'days_before': {
if (isNullish) {
return false;
}
const today = new Date();
const daysInMilliseconds = filterValue * 24 * 60 * 60 * 1000;
const targetDate = new Date(today.getTime() - daysInMilliseconds);
@@ -192,6 +192,32 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should not match conversation with equal_to operator when assignee is null', () => {
const conversation = { meta: { assignee: null } };
const filters = [
{
attribute_key: 'assignee_id',
filter_operator: 'equal_to',
values: { id: 1, name: 'John Doe' },
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should match conversation with not_equal_to operator when assignee is null', () => {
const conversation = { meta: { assignee: null } };
const filters = [
{
attribute_key: 'assignee_id',
filter_operator: 'not_equal_to',
values: { id: 1, name: 'John Doe' },
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should match conversation with is_not_present operator for assignee_id', () => {
const conversation = { meta: { assignee: null } };
const filters = [
@@ -285,6 +311,58 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should not match contains operator when display_id is null', () => {
const conversation = { id: null };
const filters = [
{
attribute_key: 'display_id',
filter_operator: 'contains',
values: '234',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should not match contains operator when filter value is null', () => {
const conversation = { id: '12345' };
const filters = [
{
attribute_key: 'display_id',
filter_operator: 'contains',
values: null,
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should match does_not_contain operator when display_id is null', () => {
const conversation = { id: null };
const filters = [
{
attribute_key: 'display_id',
filter_operator: 'does_not_contain',
values: '234',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should match does_not_contain operator when filter value is null', () => {
const conversation = { id: '12345' };
const filters = [
{
attribute_key: 'display_id',
filter_operator: 'does_not_contain',
values: null,
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should match conversation with does_not_contain operator when value is not present', () => {
const conversation = { id: '12345' };
const filters = [