Skip to content

Commit 5893b57

Browse files
authored
refactor(frontend): improve SyncStatusButton UX with smooth async sequence and toast feedback (#44)
1 parent bba0939 commit 5893b57

3 files changed

Lines changed: 149 additions & 6 deletions

File tree

frontend/src/App.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const auth = useAuthStore()
1212
const { showError } = useEnhancedToast()
1313
1414
const globalErrorHandler = (error: unknown) => {
15+
if (error && typeof error === 'object' && 'silent' in error && error.silent) return
1516
showError(error)
1617
}
1718
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
2+
import { mount } from '@vue/test-utils'
3+
import SyncStatusButton from './SyncStatusButton.vue'
4+
import { useIsFetching, useQueryClient } from '@tanstack/vue-query'
5+
import { useEnhancedToast } from '@/shared/composables/useEnhancedToast'
6+
7+
// Mock TanStack Query
8+
vi.mock('@tanstack/vue-query', () => ({
9+
useIsFetching: vi.fn(),
10+
useQueryClient: vi.fn(),
11+
}))
12+
13+
// Mock Enhanced Toast
14+
vi.mock('@/shared/composables/useEnhancedToast', () => ({
15+
useEnhancedToast: vi.fn(),
16+
}))
17+
18+
// Mock Iconify
19+
vi.mock('@iconify/vue', () => ({
20+
Icon: { template: '<span>icon</span>' },
21+
}))
22+
23+
describe('SyncStatusButton', () => {
24+
let mockQueryClient: { invalidateQueries: Mock }
25+
let mockShowSuccess: Mock
26+
let mockShowError: Mock
27+
28+
beforeEach(() => {
29+
vi.clearAllMocks()
30+
31+
mockQueryClient = {
32+
invalidateQueries: vi.fn().mockResolvedValue(undefined),
33+
}
34+
;(useQueryClient as Mock).mockReturnValue(mockQueryClient)
35+
;(useIsFetching as Mock).mockReturnValue({ value: 0 })
36+
37+
mockShowSuccess = vi.fn()
38+
mockShowError = vi.fn()
39+
;(useEnhancedToast as Mock).mockReturnValue({
40+
showSuccess: mockShowSuccess,
41+
showError: mockShowError,
42+
})
43+
44+
// Silence console during tests if needed
45+
vi.spyOn(console, 'error').mockImplementation(() => {})
46+
})
47+
48+
it('should call invalidateQueries and show success toast on click', async () => {
49+
vi.useFakeTimers()
50+
const wrapper = mount(SyncStatusButton, {
51+
global: {
52+
stubs: {
53+
TooltipProvider: { template: '<div><slot /></div>' },
54+
Tooltip: { template: '<div><slot /></div>' },
55+
TooltipTrigger: { template: '<div><slot /></div>' },
56+
TooltipContent: { template: '<div><slot /></div>' },
57+
Button: {
58+
template: '<button @click="$emit(\'click\')"><slot /></button>',
59+
props: ['disabled'],
60+
},
61+
},
62+
},
63+
})
64+
65+
const button = wrapper.find('button')
66+
await button.trigger('click')
67+
68+
// After click, it should have started invalidation
69+
expect(mockQueryClient.invalidateQueries).toHaveBeenCalled()
70+
71+
// Wait for the minimum duration (600ms)
72+
await vi.advanceTimersByTimeAsync(600)
73+
74+
expect(mockShowSuccess).toHaveBeenCalledWith(
75+
'Data synchronized',
76+
'All active data views have been updated.'
77+
)
78+
vi.useRealTimers()
79+
})
80+
81+
it('should show error toast and mark error as silent on failure', async () => {
82+
vi.useFakeTimers()
83+
const error = new Error('Sync failed')
84+
mockQueryClient.invalidateQueries.mockRejectedValue(error)
85+
86+
const wrapper = mount(SyncStatusButton, {
87+
global: {
88+
stubs: {
89+
TooltipProvider: { template: '<div><slot /></div>' },
90+
Tooltip: { template: '<div><slot /></div>' },
91+
TooltipTrigger: { template: '<div><slot /></div>' },
92+
TooltipContent: { template: '<div><slot /></div>' },
93+
Button: {
94+
template: '<button @click="$emit(\'click\')"><slot /></button>',
95+
props: ['disabled'],
96+
},
97+
},
98+
},
99+
})
100+
101+
const button = wrapper.find('button')
102+
await button.trigger('click')
103+
104+
await vi.advanceTimersByTimeAsync(600)
105+
106+
expect(mockShowError).toHaveBeenCalledWith(error, 'Sync Failed')
107+
expect((error as Error & { silent?: boolean }).silent).toBe(true)
108+
vi.useRealTimers()
109+
})
110+
})

frontend/src/shared/components/layout/SyncStatusButton.vue

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,43 @@
11
<script setup lang="ts">
2+
import { ref, computed } from 'vue'
23
import { useIsFetching, useQueryClient } from '@tanstack/vue-query'
34
import { Icon } from '@iconify/vue'
45
import { Button } from '@/shared/ui/button'
56
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/shared/ui/tooltip'
7+
import { useEnhancedToast } from '@/shared/composables/useEnhancedToast'
68
79
const isFetching = useIsFetching()
810
const queryClient = useQueryClient()
11+
const { showSuccess, showError } = useEnhancedToast()
912
10-
const handleRefresh = () => {
11-
queryClient.invalidateQueries()
13+
const isSyncing = ref(false)
14+
15+
// Combine TanStack's background fetching with our artificial minimum duration
16+
const isRefreshing = computed(() => isSyncing.value || isFetching.value > 0)
17+
18+
const handleRefresh = async () => {
19+
if (isSyncing.value) return
20+
21+
isSyncing.value = true
22+
23+
try {
24+
// We wrap invalidateQueries in a Promise.all with a minimum timeout
25+
// to ensure the UI animation feels deliberate and smooth (no "blinks")
26+
await Promise.all([
27+
queryClient.invalidateQueries(),
28+
new Promise(resolve => setTimeout(resolve, 600)),
29+
])
30+
31+
showSuccess('Data synchronized', 'All active data views have been updated.')
32+
} catch (error) {
33+
// Mark as silent to prevent App.vue's global handler from showing a duplicate toast
34+
if (error && typeof error === 'object') {
35+
;(error as { silent?: boolean }).silent = true
36+
}
37+
showError(error, 'Sync Failed')
38+
} finally {
39+
isSyncing.value = false
40+
}
1241
}
1342
</script>
1443

@@ -20,16 +49,19 @@ const handleRefresh = () => {
2049
variant="ghost"
2150
size="icon"
2251
class="h-8 w-8"
23-
:disabled="isFetching > 0"
52+
:disabled="isRefreshing"
2453
@click="handleRefresh"
2554
>
26-
<Icon v-if="isFetching > 0" icon="radix-icons:update" class="h-4 w-4 animate-spin" />
27-
<Icon v-else icon="radix-icons:update" class="h-4 w-4" />
55+
<Icon
56+
icon="radix-icons:update"
57+
class="h-4 w-4"
58+
:class="{ 'animate-spin': isRefreshing }"
59+
/>
2860
<span class="sr-only">Refresh data</span>
2961
</Button>
3062
</TooltipTrigger>
3163
<TooltipContent>
32-
<p>{{ isFetching > 0 ? 'Syncing data...' : 'Refresh all data' }}</p>
64+
<p>{{ isRefreshing ? 'Syncing data...' : 'Refresh all data' }}</p>
3365
</TooltipContent>
3466
</Tooltip>
3567
</TooltipProvider>

0 commit comments

Comments
 (0)