From c910020fa542a5083996f844d9b9f962de7934d7 Mon Sep 17 00:00:00 2001 From: Ivan Skvortsov Date: Fri, 15 May 2026 14:01:09 +0200 Subject: [PATCH] refactor(frontend): improve SyncStatusButton UX with smooth async sequence and toast feedback --- frontend/src/App.vue | 1 + .../layout/SyncStatusButton.spec.ts | 110 ++++++++++++++++++ .../components/layout/SyncStatusButton.vue | 44 ++++++- 3 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 frontend/src/shared/components/layout/SyncStatusButton.spec.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d3b6227..fd1e714 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -12,6 +12,7 @@ const auth = useAuthStore() const { showError } = useEnhancedToast() const globalErrorHandler = (error: unknown) => { + if (error && typeof error === 'object' && 'silent' in error && error.silent) return showError(error) } diff --git a/frontend/src/shared/components/layout/SyncStatusButton.spec.ts b/frontend/src/shared/components/layout/SyncStatusButton.spec.ts new file mode 100644 index 0000000..dbf40e0 --- /dev/null +++ b/frontend/src/shared/components/layout/SyncStatusButton.spec.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' +import { mount } from '@vue/test-utils' +import SyncStatusButton from './SyncStatusButton.vue' +import { useIsFetching, useQueryClient } from '@tanstack/vue-query' +import { useEnhancedToast } from '@/shared/composables/useEnhancedToast' + +// Mock TanStack Query +vi.mock('@tanstack/vue-query', () => ({ + useIsFetching: vi.fn(), + useQueryClient: vi.fn(), +})) + +// Mock Enhanced Toast +vi.mock('@/shared/composables/useEnhancedToast', () => ({ + useEnhancedToast: vi.fn(), +})) + +// Mock Iconify +vi.mock('@iconify/vue', () => ({ + Icon: { template: 'icon' }, +})) + +describe('SyncStatusButton', () => { + let mockQueryClient: { invalidateQueries: Mock } + let mockShowSuccess: Mock + let mockShowError: Mock + + beforeEach(() => { + vi.clearAllMocks() + + mockQueryClient = { + invalidateQueries: vi.fn().mockResolvedValue(undefined), + } + ;(useQueryClient as Mock).mockReturnValue(mockQueryClient) + ;(useIsFetching as Mock).mockReturnValue({ value: 0 }) + + mockShowSuccess = vi.fn() + mockShowError = vi.fn() + ;(useEnhancedToast as Mock).mockReturnValue({ + showSuccess: mockShowSuccess, + showError: mockShowError, + }) + + // Silence console during tests if needed + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('should call invalidateQueries and show success toast on click', async () => { + vi.useFakeTimers() + const wrapper = mount(SyncStatusButton, { + global: { + stubs: { + TooltipProvider: { template: '
' }, + Tooltip: { template: '
' }, + TooltipTrigger: { template: '
' }, + TooltipContent: { template: '
' }, + Button: { + template: '', + props: ['disabled'], + }, + }, + }, + }) + + const button = wrapper.find('button') + await button.trigger('click') + + // After click, it should have started invalidation + expect(mockQueryClient.invalidateQueries).toHaveBeenCalled() + + // Wait for the minimum duration (600ms) + await vi.advanceTimersByTimeAsync(600) + + expect(mockShowSuccess).toHaveBeenCalledWith( + 'Data synchronized', + 'All active data views have been updated.' + ) + vi.useRealTimers() + }) + + it('should show error toast and mark error as silent on failure', async () => { + vi.useFakeTimers() + const error = new Error('Sync failed') + mockQueryClient.invalidateQueries.mockRejectedValue(error) + + const wrapper = mount(SyncStatusButton, { + global: { + stubs: { + TooltipProvider: { template: '
' }, + Tooltip: { template: '
' }, + TooltipTrigger: { template: '
' }, + TooltipContent: { template: '
' }, + Button: { + template: '', + props: ['disabled'], + }, + }, + }, + }) + + const button = wrapper.find('button') + await button.trigger('click') + + await vi.advanceTimersByTimeAsync(600) + + expect(mockShowError).toHaveBeenCalledWith(error, 'Sync Failed') + expect((error as Error & { silent?: boolean }).silent).toBe(true) + vi.useRealTimers() + }) +}) diff --git a/frontend/src/shared/components/layout/SyncStatusButton.vue b/frontend/src/shared/components/layout/SyncStatusButton.vue index 9141fbf..e271a8c 100644 --- a/frontend/src/shared/components/layout/SyncStatusButton.vue +++ b/frontend/src/shared/components/layout/SyncStatusButton.vue @@ -1,14 +1,43 @@ @@ -20,16 +49,19 @@ const handleRefresh = () => { variant="ghost" size="icon" class="h-8 w-8" - :disabled="isFetching > 0" + :disabled="isRefreshing" @click="handleRefresh" > - - + Refresh data -

{{ isFetching > 0 ? 'Syncing data...' : 'Refresh all data' }}

+

{{ isRefreshing ? 'Syncing data...' : 'Refresh all data' }}