Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
110 changes: 110 additions & 0 deletions frontend/src/shared/components/layout/SyncStatusButton.spec.ts
Original file line number Diff line number Diff line change
@@ -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: '<span>icon</span>' },
}))

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: '<div><slot /></div>' },
Tooltip: { template: '<div><slot /></div>' },
TooltipTrigger: { template: '<div><slot /></div>' },
TooltipContent: { template: '<div><slot /></div>' },
Button: {
template: '<button @click="$emit(\'click\')"><slot /></button>',
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: '<div><slot /></div>' },
Tooltip: { template: '<div><slot /></div>' },
TooltipTrigger: { template: '<div><slot /></div>' },
TooltipContent: { template: '<div><slot /></div>' },
Button: {
template: '<button @click="$emit(\'click\')"><slot /></button>',
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()
})
})
44 changes: 38 additions & 6 deletions frontend/src/shared/components/layout/SyncStatusButton.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,43 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useIsFetching, useQueryClient } from '@tanstack/vue-query'
import { Icon } from '@iconify/vue'
import { Button } from '@/shared/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/shared/ui/tooltip'
import { useEnhancedToast } from '@/shared/composables/useEnhancedToast'

const isFetching = useIsFetching()
const queryClient = useQueryClient()
const { showSuccess, showError } = useEnhancedToast()

const handleRefresh = () => {
queryClient.invalidateQueries()
const isSyncing = ref(false)

// Combine TanStack's background fetching with our artificial minimum duration
const isRefreshing = computed(() => isSyncing.value || isFetching.value > 0)

const handleRefresh = async () => {
if (isSyncing.value) return

isSyncing.value = true

try {
// We wrap invalidateQueries in a Promise.all with a minimum timeout
// to ensure the UI animation feels deliberate and smooth (no "blinks")
await Promise.all([
queryClient.invalidateQueries(),
new Promise(resolve => setTimeout(resolve, 600)),
])

showSuccess('Data synchronized', 'All active data views have been updated.')
} catch (error) {
// Mark as silent to prevent App.vue's global handler from showing a duplicate toast
if (error && typeof error === 'object') {
;(error as { silent?: boolean }).silent = true
}
showError(error, 'Sync Failed')
} finally {
isSyncing.value = false
}
}
</script>

Expand All @@ -20,16 +49,19 @@ const handleRefresh = () => {
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="isFetching > 0"
:disabled="isRefreshing"
@click="handleRefresh"
>
<Icon v-if="isFetching > 0" icon="radix-icons:update" class="h-4 w-4 animate-spin" />
<Icon v-else icon="radix-icons:update" class="h-4 w-4" />
<Icon
icon="radix-icons:update"
class="h-4 w-4"
:class="{ 'animate-spin': isRefreshing }"
/>
<span class="sr-only">Refresh data</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ isFetching > 0 ? 'Syncing data...' : 'Refresh all data' }}</p>
<p>{{ isRefreshing ? 'Syncing data...' : 'Refresh all data' }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
Expand Down
Loading