fix: stale report value shown if summary fetch breaks (#11270)

This pull request includes several changes to better show the report
metrics fetching status on the dashboard. This also prevents showing
stale data in case fetching summary fails due to timeout or other issue

The most important changes include adding a new fetching status state to
the store called `summaryFetchingStatus`, updating the
`useReportMetrics` composable, and modifying the `ChartStats` component
to handle different fetching statuses.

#### Loading
![CleanShot 2025-04-09 at 13 49
35@2x](https://github.com/user-attachments/assets/575c9905-0bf7-4a6e-8709-026896dd95f8)

#### Finished
![CleanShot 2025-04-09 at 13 49
43@2x](https://github.com/user-attachments/assets/ef7f8cb3-ca34-4627-a954-ba23f156d2ff)

#### Failed
<img width="1512" alt="image"
src="https://github.com/user-attachments/assets/d521a785-9299-4e59-94dc-561a7a84377e"
/>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Shivam Mishra
2025-04-15 20:18:06 +05:30
committed by GitHub
parent 79fc5bb555
commit 1140ca7a78
8 changed files with 192 additions and 13 deletions

View File

@@ -7,8 +7,12 @@ import { formatTime } from '@chatwoot/utils';
* @param {string} [accountSummaryKey='getAccountSummary'] - The key for accessing account summary data. * @param {string} [accountSummaryKey='getAccountSummary'] - The key for accessing account summary data.
* @returns {Object} An object containing utility functions for report metrics. * @returns {Object} An object containing utility functions for report metrics.
*/ */
export function useReportMetrics(accountSummaryKey = 'getAccountSummary') { export function useReportMetrics(
accountSummaryKey = 'getAccountSummary',
summarFetchingKey = 'getAccountSummaryFetchingStatus'
) {
const accountSummary = useMapGetter(accountSummaryKey); const accountSummary = useMapGetter(accountSummaryKey);
const fetchingStatus = useMapGetter(summarFetchingKey);
/** /**
* Calculates the trend percentage for a given metric. * Calculates the trend percentage for a given metric.
@@ -53,5 +57,6 @@ export function useReportMetrics(accountSummaryKey = 'getAccountSummary') {
calculateTrend, calculateTrend,
isAverageMetricType, isAverageMetricType,
displayMetric, displayMetric,
fetchingStatus,
}; };
} }

View File

@@ -98,6 +98,7 @@ export default {
<BotMetrics :filters="requestPayload" /> <BotMetrics :filters="requestPayload" />
<ReportContainer <ReportContainer
account-summary-key="getBotSummary" account-summary-key="getBotSummary"
summary-fetching-key="getBotSummaryFetchingStatus"
:group-by="groupBy" :group-by="groupBy"
:report-keys="reportKeys" :report-keys="reportKeys"
/> />

View File

@@ -19,6 +19,10 @@ export default {
type: String, type: String,
default: 'getAccountSummary', default: 'getAccountSummary',
}, },
summaryFetchingKey: {
type: String,
default: 'getAccountSummaryFetchingStatus',
},
reportKeys: { reportKeys: {
type: Object, type: Object,
default: () => ({ default: () => ({
@@ -148,7 +152,11 @@ export default {
:key="metric.KEY" :key="metric.KEY"
class="p-4 mb-3 rounded-md" class="p-4 mb-3 rounded-md"
> >
<ChartStats :metric="metric" :account-summary-key="accountSummaryKey" /> <ChartStats
:metric="metric"
:account-summary-key="accountSummaryKey"
:summary-fetching-key="summaryFetchingKey"
/>
<div class="mt-4 h-72"> <div class="mt-4 h-72">
<woot-loading-state <woot-loading-state
v-if="accountReport.isFetching[metric.KEY]" v-if="accountReport.isFetching[metric.KEY]"

View File

@@ -1,5 +1,8 @@
<script setup> <script setup>
import { useReportMetrics } from 'dashboard/composables/useReportMetrics'; import { useReportMetrics } from 'dashboard/composables/useReportMetrics';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import { STATUS } from 'dashboard/store/constants';
import { useI18n } from 'vue-i18n';
const props = defineProps({ const props = defineProps({
metric: { metric: {
@@ -10,11 +13,16 @@ const props = defineProps({
type: String, type: String,
default: 'getAccountSummary', default: 'getAccountSummary',
}, },
summaryFetchingKey: {
type: String,
default: 'getAccountSummaryFetchingStatus',
},
}); });
const { calculateTrend, displayMetric, isAverageMetricType } = useReportMetrics( const { t } = useI18n();
props.accountSummaryKey
); const { calculateTrend, displayMetric, isAverageMetricType, fetchingStatus } =
useReportMetrics(props.accountSummaryKey, props.summaryFetchingKey);
const trendColor = (value, key) => { const trendColor = (value, key) => {
if (isAverageMetricType(key)) { if (isAverageMetricType(key)) {
@@ -34,10 +42,25 @@ const trendColor = (value, key) => {
{{ metric.NAME }} {{ metric.NAME }}
</span> </span>
<div class="flex items-end text-n-slate-12"> <div class="flex items-end text-n-slate-12">
<div class="text-xl font-medium"> <div v-if="fetchingStatus === STATUS.FETCHING">
<Spinner />
</div>
<div
v-else-if="fetchingStatus === STATUS.FAILED"
class="text-n-ruby-10 text-sm"
>
{{ t('REPORT.SUMMARY_FETCHING_FAILED') }}
</div>
<div
v-else-if="fetchingStatus === STATUS.FINISHED"
class="text-xl font-medium"
>
{{ displayMetric(metric.KEY) }} {{ displayMetric(metric.KEY) }}
</div> </div>
<div v-if="metric.trend" class="text-xs ml-4 flex items-center mb-0.5"> <div
v-if="metric.trend && fetchingStatus === STATUS.FINISHED"
class="text-xs ml-4 flex items-center mb-0.5"
>
<div <div
v-if="metric.trend < 0" v-if="metric.trend < 0"
class="h-0 w-0 border-x-4 medium border-x-transparent border-t-[8px] mr-1" class="h-0 w-0 border-x-4 medium border-x-transparent border-t-[8px] mr-1"

View File

@@ -0,0 +1,5 @@
export const STATUS = {
FAILED: 'failed',
FETCHING: 'fetching',
FINISHED: 'finished',
};

View File

@@ -1,5 +1,6 @@
/* eslint no-console: 0 */ /* eslint no-console: 0 */
import * as types from '../mutation-types'; import * as types from '../mutation-types';
import { STATUS } from '../constants';
import Report from '../../api/reports'; import Report from '../../api/reports';
import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper'; import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
import AnalyticsHelper from '../../helper/AnalyticsHelper'; import AnalyticsHelper from '../../helper/AnalyticsHelper';
@@ -9,6 +10,8 @@ import liveReports from '../../api/liveReports';
const state = { const state = {
fetchingStatus: false, fetchingStatus: false,
accountSummaryFetchingStatus: STATUS.FINISHED,
botSummaryFetchingStatus: STATUS.FINISHED,
accountReport: { accountReport: {
isFetching: { isFetching: {
conversations_count: false, conversations_count: false,
@@ -74,6 +77,12 @@ const getters = {
getBotSummary(_state) { getBotSummary(_state) {
return _state.botSummary; return _state.botSummary;
}, },
getAccountSummaryFetchingStatus(_state) {
return _state.accountSummaryFetchingStatus;
},
getBotSummaryFetchingStatus(_state) {
return _state.botSummaryFetchingStatus;
},
getAccountConversationMetric(_state) { getAccountConversationMetric(_state) {
return _state.overview.accountConversationMetric; return _state.overview.accountConversationMetric;
}, },
@@ -122,6 +131,7 @@ export const actions = {
}); });
}, },
fetchAccountSummary({ commit }, reportObj) { fetchAccountSummary({ commit }, reportObj) {
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING);
Report.getSummary( Report.getSummary(
reportObj.from, reportObj.from,
reportObj.to, reportObj.to,
@@ -132,12 +142,14 @@ export const actions = {
) )
.then(accountSummary => { .then(accountSummary => {
commit(types.default.SET_ACCOUNT_SUMMARY, accountSummary.data); commit(types.default.SET_ACCOUNT_SUMMARY, accountSummary.data);
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FINISHED);
}) })
.catch(() => { .catch(() => {
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false); commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FAILED);
}); });
}, },
fetchBotSummary({ commit }, reportObj) { fetchBotSummary({ commit }, reportObj) {
commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FETCHING);
Report.getBotSummary({ Report.getBotSummary({
from: reportObj.from, from: reportObj.from,
to: reportObj.to, to: reportObj.to,
@@ -146,9 +158,10 @@ export const actions = {
}) })
.then(botSummary => { .then(botSummary => {
commit(types.default.SET_BOT_SUMMARY, botSummary.data); commit(types.default.SET_BOT_SUMMARY, botSummary.data);
commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FINISHED);
}) })
.catch(() => { .catch(() => {
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false); commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FAILED);
}); });
}, },
fetchAccountConversationMetric({ commit }, params = {}) { fetchAccountConversationMetric({ commit }, params = {}) {
@@ -277,6 +290,12 @@ const mutations = {
[types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) { [types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) {
_state.accountReport.isFetching[metric] = value; _state.accountReport.isFetching[metric] = value;
}, },
[types.default.SET_BOT_SUMMARY_STATUS](_state, status) {
_state.botSummaryFetchingStatus = status;
},
[types.default.SET_ACCOUNT_SUMMARY_STATUS](_state, status) {
_state.accountSummaryFetchingStatus = status;
},
[types.default.TOGGLE_HEATMAP_LOADING](_state, flag) { [types.default.TOGGLE_HEATMAP_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag; _state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag;
}, },

View File

@@ -1,14 +1,122 @@
import axios from 'axios'; import axios from 'axios';
import { actions } from '../../reports'; import { actions } from '../../reports';
import * as types from '../../../mutation-types';
import { STATUS } from '../../../constants';
import * as DownloadHelper from 'dashboard/helper/downloadHelper'; import * as DownloadHelper from 'dashboard/helper/downloadHelper';
import { flushPromises } from '@vue/test-utils';
global.open = vi.fn(); global.open = vi.fn();
global.axios = axios; global.axios = axios;
global.URL.createObjectURL = vi.fn();
vi.mock('axios'); vi.mock('axios');
vi.spyOn(DownloadHelper, 'downloadCsvFile'); vi.spyOn(DownloadHelper, 'downloadCsvFile');
describe('#actions', () => { describe('#actions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('#fetchAccountSummary', () => {
it('sends correct actions if API is success', async () => {
const commit = vi.fn();
const reportObj = {
from: 1630504922510,
to: 1630504922510,
type: 'account',
id: 1,
groupBy: 'day',
businessHours: true,
};
const summaryData = {
conversations_count: 10,
incoming_messages_count: 20,
outgoing_messages_count: 15,
avg_first_response_time: 30,
avg_resolution_time: 60,
resolutions_count: 5,
bot_resolutions_count: 2,
bot_handoffs_count: 1,
reply_time: 25,
};
axios.get.mockResolvedValue({ data: summaryData });
actions.fetchAccountSummary({ commit }, reportObj);
await flushPromises();
expect(commit.mock.calls).toEqual([
[types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING],
[types.default.SET_ACCOUNT_SUMMARY, summaryData],
[types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FINISHED],
]);
});
it('sends correct actions if API fails', async () => {
const commit = vi.fn();
const reportObj = {
from: 1630504922510,
to: 1630504922510,
};
axios.get.mockRejectedValue(new Error('API Error'));
actions.fetchAccountSummary({ commit }, reportObj);
await flushPromises();
expect(commit.mock.calls).toEqual([
[types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING],
[types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FAILED],
]);
});
});
describe('#fetchBotSummary', () => {
it('sends correct actions if API is success', async () => {
const commit = vi.fn();
const reportObj = {
from: 1630504922510,
to: 1630504922510,
groupBy: 'day',
businessHours: true,
};
const summaryData = {
bot_resolutions_count: 10,
bot_handoffs_count: 5,
previous: {
bot_resolutions_count: 8,
bot_handoffs_count: 4,
},
};
axios.get.mockResolvedValue({ data: summaryData });
actions.fetchBotSummary({ commit }, reportObj);
await flushPromises();
expect(commit.mock.calls).toEqual([
[types.default.SET_BOT_SUMMARY_STATUS, STATUS.FETCHING],
[types.default.SET_BOT_SUMMARY, summaryData],
[types.default.SET_BOT_SUMMARY_STATUS, STATUS.FINISHED],
]);
});
it('sends correct actions if API fails', async () => {
const commit = vi.fn();
const reportObj = {
from: 1630504922510,
to: 1630504922510,
};
const error = new Error('API error');
axios.get.mockRejectedValueOnce(error);
actions.fetchBotSummary({ commit }, reportObj);
await flushPromises();
expect(commit.mock.calls).toEqual([
[types.default.SET_BOT_SUMMARY_STATUS, STATUS.FETCHING],
[types.default.SET_BOT_SUMMARY_STATUS, STATUS.FAILED],
]);
});
});
describe('#downloadAgentReports', () => { describe('#downloadAgentReports', () => {
it('open CSV download prompt if API is success', async () => { it('open CSV download prompt if API is success', async () => {
const data = `Agent name,Conversations count,Avg first response time (Minutes),Avg resolution time (Minutes) const data = `Agent name,Conversations count,Avg first response time (Minutes),Avg resolution time (Minutes)
@@ -20,7 +128,9 @@ describe('#actions', () => {
to: 1630504922510, to: 1630504922510,
fileName: 'agent-report-01-09-2021.csv', fileName: 'agent-report-01-09-2021.csv',
}; };
await actions.downloadAgentReports(1, param); actions.downloadAgentReports(1, param);
await flushPromises();
expect(DownloadHelper.downloadCsvFile).toBeCalledWith( expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
param.fileName, param.fileName,
data data
@@ -39,7 +149,9 @@ describe('#actions', () => {
type: 'label', type: 'label',
fileName: 'label-report-01-09-2021.csv', fileName: 'label-report-01-09-2021.csv',
}; };
await actions.downloadLabelReports(1, param); actions.downloadLabelReports(1, param);
await flushPromises();
expect(DownloadHelper.downloadCsvFile).toBeCalledWith( expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
param.fileName, param.fileName,
data data
@@ -59,7 +171,9 @@ describe('#actions', () => {
to: 1635013800, to: 1635013800,
fileName: 'inbox-report-24-10-2021.csv', fileName: 'inbox-report-24-10-2021.csv',
}; };
await actions.downloadInboxReports(1, param); actions.downloadInboxReports(1, param);
await flushPromises();
expect(DownloadHelper.downloadCsvFile).toBeCalledWith( expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
param.fileName, param.fileName,
data data
@@ -78,7 +192,9 @@ describe('#actions', () => {
to: 1635013800, to: 1635013800,
fileName: 'inbox-report-24-10-2021.csv', fileName: 'inbox-report-24-10-2021.csv',
}; };
await actions.downloadInboxReports(1, param); actions.downloadInboxReports(1, param);
await flushPromises();
expect(DownloadHelper.downloadCsvFile).toBeCalledWith( expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
param.fileName, param.fileName,
data data

View File

@@ -189,6 +189,8 @@ export default {
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY', SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
SET_BOT_SUMMARY: 'SET_BOT_SUMMARY', SET_BOT_SUMMARY: 'SET_BOT_SUMMARY',
TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING', TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING',
SET_BOT_SUMMARY_STATUS: 'SET_BOT_SUMMARY_STATUS',
SET_ACCOUNT_SUMMARY_STATUS: 'SET_ACCOUNT_SUMMARY_STATUS',
SET_ACCOUNT_CONVERSATION_METRIC: 'SET_ACCOUNT_CONVERSATION_METRIC', SET_ACCOUNT_CONVERSATION_METRIC: 'SET_ACCOUNT_CONVERSATION_METRIC',
TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING: TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING:
'TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING', 'TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING',