fix: add loading and silent retry to summary reports (#13455)
For large accounts, summary report queries can take several seconds to complete, often times hitting the 15-second production request timeout. The existing implementation silently swallows these failures and provides no feedback during loading. Users see stale data with no indication that a fetch is in progress, and if they interact with filters while a request is in flight, they trigger race conditions that can result in mismatched data being displayed. This is a UX-level fix for what is fundamentally a performance problem. While the underlying query performance is addressed separately, users need proper feedback either way ## Approach The PR adds three things: 1. A loading overlay on the table, to provide feedback on loading state 2. Disabled filter inputs during loading so that the user does not request new information that can cause race conditions in updating the store 3. Silent retry before showing an error. The retry exists because these queries often succeed on the second attempt—likely due to database query caching. Rather than immediately showing an error and forcing the user to manually retry, we do it automatically. If the second attempt also fails, we show a toast so the user knows something went wrong. The store previously caught and discarded errors entirely. It now rethrows them after resetting the loading flag, allowing components to handle failures as they see fit. ### Previews #### Double Retry and Error https://github.com/user-attachments/assets/c189b173-8017-44b7-9493-417d65582c95 #### Loading State https://github.com/user-attachments/assets/9f899c20-fbad-469b-93cc-f0d05d0853b0 --------- Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
@@ -11,6 +11,13 @@ import {
|
||||
} from '../helpers/reportFilterHelper';
|
||||
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
|
||||
|
||||
defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['filterChange']);
|
||||
|
||||
const route = useRoute();
|
||||
@@ -79,7 +86,10 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col justify-between gap-3 md:flex-row">
|
||||
<div
|
||||
class="flex flex-col justify-between gap-3 md:flex-row"
|
||||
:class="{ 'pointer-events-none opacity-50': disabled }"
|
||||
>
|
||||
<div class="flex flex-col flex-wrap items-start gap-2 md:flex-row">
|
||||
<WootDatePicker
|
||||
v-model:date-range="customDateRange"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import OverviewReportFilters from './OverviewReportFilters.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Table from 'dashboard/components/table/Table.vue';
|
||||
import { generateFileName } from 'dashboard/helper/downloadHelper';
|
||||
import {
|
||||
@@ -42,6 +44,16 @@ const businessHours = ref(false);
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import SummaryReportLink from './SummaryReportLink.vue';
|
||||
|
||||
const flagMap = {
|
||||
agent: 'isFetchingAgentSummaryReports',
|
||||
inbox: 'isFetchingInboxSummaryReports',
|
||||
team: 'isFetchingTeamSummaryReports',
|
||||
label: 'isFetchingLabelSummaryReports',
|
||||
};
|
||||
|
||||
const uiFlags = useMapGetter('summaryReports/getUIFlags');
|
||||
const isLoading = computed(() => uiFlags.value[flagMap[props.type]] ?? false);
|
||||
|
||||
const rowItems = useMapGetter([props.getterKey]) || [];
|
||||
const reportMetrics = useMapGetter([props.summaryKey]) || [];
|
||||
|
||||
@@ -120,13 +132,26 @@ const tableData = computed(() =>
|
||||
})
|
||||
);
|
||||
|
||||
const fetchAllData = () => {
|
||||
store.dispatch(props.fetchItemsKey);
|
||||
store.dispatch(props.actionKey, {
|
||||
const fetchReportsWithRetry = async () => {
|
||||
const params = {
|
||||
since: from.value,
|
||||
until: to.value,
|
||||
businessHours: businessHours.value,
|
||||
});
|
||||
};
|
||||
try {
|
||||
await store.dispatch(props.actionKey, params);
|
||||
} catch {
|
||||
try {
|
||||
await store.dispatch(props.actionKey, params);
|
||||
} catch {
|
||||
useAlert(t('REPORT.SUMMARY_FETCHING_FAILED'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAllData = () => {
|
||||
store.dispatch(props.fetchItemsKey);
|
||||
fetchReportsWithRetry();
|
||||
};
|
||||
|
||||
onMounted(() => fetchAllData());
|
||||
@@ -178,10 +203,28 @@ defineExpose({ downloadReports });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OverviewReportFilters @filter-change="onFilterChange" />
|
||||
<OverviewReportFilters
|
||||
:disabled="isLoading"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
<div
|
||||
class="flex-1 overflow-auto px-2 py-2 mt-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
|
||||
class="relative flex-1 overflow-auto px-2 py-2 mt-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
|
||||
>
|
||||
<Table :table="table" />
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-300 ease-out"
|
||||
leave-active-class="transition-opacity duration-200 ease-in"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="absolute inset-0 flex justify-center pt-[12.5rem] bg-n-solid-1/70 rounded-xl pointer-events-none"
|
||||
>
|
||||
<Spinner :size="32" class="text-n-brand" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -141,12 +141,14 @@ describe('Summary Reports Store', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
it('should reset uiFlags and rethrow error on failure', async () => {
|
||||
SummaryReportsAPI.getInboxReports.mockRejectedValue(
|
||||
new Error('API Error')
|
||||
);
|
||||
|
||||
await store.actions.fetchInboxSummaryReports({ commit }, {});
|
||||
await expect(
|
||||
store.actions.fetchInboxSummaryReports({ commit }, {})
|
||||
).rejects.toThrow('API Error');
|
||||
|
||||
expect(commit).toHaveBeenCalledWith('setUIFlags', {
|
||||
isFetchingInboxSummaryReports: false,
|
||||
|
||||
@@ -28,15 +28,17 @@ async function fetchSummaryReports(type, params, { commit }) {
|
||||
const config = typeMap[type];
|
||||
if (!config) return;
|
||||
|
||||
let error = null;
|
||||
try {
|
||||
commit('setUIFlags', { [config.flagKey]: true });
|
||||
const response = await SummaryReportsAPI[config.apiMethod](params);
|
||||
commit(config.mutationKey, camelcaseKeys(response.data, { deep: true }));
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
} catch (e) {
|
||||
error = e;
|
||||
} finally {
|
||||
commit('setUIFlags', { [config.flagKey]: false });
|
||||
}
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export const initialState = {
|
||||
|
||||
Reference in New Issue
Block a user