Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
19 changes: 12 additions & 7 deletions src/components/dialog/content/setting/CreditsPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,18 @@ vi.mock('@/composables/billing/useBillingContext', async () => {
}
})

const refreshActivity = vi.hoisted(() => vi.fn())
vi.mock('./UsageLogsTable.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
setup(_props, { expose }) {
expose({ refresh: refreshActivity })
return () => h('div', { 'data-testid': 'usage-logs-table' })
props: { refetchKey: { type: Number, default: 0 } },
setup(props) {
return () =>
h(
'div',
{ 'data-testid': 'usage-logs-table' },
String(props.refetchKey)
)
}
})
}
Expand Down Expand Up @@ -103,15 +107,16 @@ describe('CreditsPanel', () => {
expect(billingMocks.manageSubscription).toHaveBeenCalledOnce()
})

it('refreshes activity on a balance change but not on first hydration', async () => {
it('bumps the activity refetch key on a balance change but not on first hydration', async () => {
renderComponent()
const activityTable = screen.getByTestId('usage-logs-table')

billingMocks.balance.value = makeBalance(5000)
await nextTick()
expect(refreshActivity).not.toHaveBeenCalled()
expect(activityTable.textContent).toBe('0')

billingMocks.balance.value = makeBalance(9000)
await nextTick()
expect(refreshActivity).toHaveBeenCalledOnce()
expect(activityTable.textContent).toBe('1')
})
})
10 changes: 7 additions & 3 deletions src/components/dialog/content/setting/CreditsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
</Button>
</div>

<UsageLogsTable v-if="!embedded" ref="usageLogsTableRef" />
<UsageLogsTable v-if="!embedded" :refetch-key="creditsRevision" />

<div v-if="!embedded" class="flex flex-row gap-2">
<Button variant="muted-textonly" @click="handleFaqClick">
Expand Down Expand Up @@ -80,11 +80,15 @@ const { balance, manageSubscription } = useBillingContext()
const commandStore = useCommandStore()
const telemetry = useTelemetry()

const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
// A genuine balance change (not the first hydration) is a reason for the
// activity table to reload. This is a plain reactive value passed down as a
// prop, rather than the parent reaching into the child to call a method it
// exposes for that purpose.
const creditsRevision = ref(0)

watch(balance, (next, previous) => {
if (!next || !previous) return
void usageLogsTableRef.value?.refresh()
creditsRevision.value++
})

const handleCreditsHistoryClick = async () => {
Expand Down
17 changes: 10 additions & 7 deletions src/components/dialog/content/setting/UsageLogsTable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,15 +354,18 @@ describe('UsageLogsTable', () => {
limit: 7
})
})
})

describe('component methods', () => {
it('calls getMyEvents on refresh with page 1', async () => {
await renderLoaded()
it('refetches page 1 when refetchKey changes', async () => {
const { rerender } = await renderLoaded()
mockCustomerEventsService.getMyEvents.mockClear()

expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
page: 1,
limit: 7
await rerender({ refetchKey: 1 })

await waitFor(() => {
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
page: 1,
limit: 7
})
})
})
})
Expand Down
160 changes: 62 additions & 98 deletions src/components/dialog/content/setting/UsageLogsTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
v-else
:value="events"
:paginator="true"
:rows="pagination.limit"
:total-records="pagination.total"
:first="dataTableFirst"
:rows="limit"
:total-records="total"
:first="first"
:lazy="true"
class="p-datatable-sm custom-datatable"
@page="onPageChange"
Expand Down Expand Up @@ -96,11 +96,12 @@ import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, watch } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'

import Button from '@/components/ui/button/Button.vue'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import { usePaginatedQuery } from '@/composables/usePaginatedQuery'
import { useTelemetry } from '@/platform/telemetry'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import type { AuditLog } from '@/services/customerEventsService'
Expand All @@ -109,120 +110,83 @@ import {
useCustomerEventsService
} from '@/services/customerEventsService'

const { t } = useI18n()
const { refetchKey = 0 } = defineProps<{
/**
* Bumped by the parent to signal an out-of-band reason to reload (e.g. a
* balance change) without reaching into this component's internals.
*/
refetchKey?: number
}>()

const events = ref<AuditLog[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const { t } = useI18n()

const customerEventService = useCustomerEventsService()

const { shouldUseWorkspaceBilling } = useBillingRouting()

const pagination = ref({
page: 1,
limit: 7,
total: 0,
totalPages: 0
})

const dataTableFirst = computed(
() => (pagination.value.page - 1) * pagination.value.limit
// A billing-rail flip or a parent-signalled refetch are both reasons to
// reset to page 1 and reload against the (possibly new) backend.
const queryKey = computed(
() => `${shouldUseWorkspaceBilling.value}:${refetchKey}`
)

const tooltipContentMap = computed(() => {
const map = new Map<string, string>()
events.value.forEach((event) => {
if (customerEventService.hasAdditionalInfo(event) && event.event_id) {
map.set(event.event_id, customerEventService.getTooltipContent(event))
}
})
return map
})

// A billing-route flip can overlap two loads against different backends; only
// the latest may mutate state, so a superseded response is discarded.
let latestLoadToken = 0

const loadEvents = async () => {
const loadToken = ++latestLoadToken
loading.value = true
error.value = null

try {
const params = {
page: pagination.value.page,
limit: pagination.value.limit
const {
items: events,
limit,
total,
first,
loading,
error,
goToPage
} = usePaginatedQuery<AuditLog, string>({
key: queryKey,
initialLimit: 7,
fetchPage: async ({ page, limit: requestedLimit }) => {
const params = { page, limit: requestedLimit }

let response
try {
response = shouldUseWorkspaceBilling.value
? await workspaceApi.getBillingEvents(params)
: await customerEventService.getMyEvents(params)
} catch (err) {
console.error('Error loading events:', err)
throw new Error(t('credits.loadEventsUnknownError'), { cause: err })
}
const response = shouldUseWorkspaceBilling.value
? await workspaceApi.getBillingEvents(params)
: await customerEventService.getMyEvents(params)

// Completion telemetry must run even when a mid-checkout route flip
// supersedes this load, since legacy and workspace backends emit different
// top-up events and the winning fetch may not carry the completion yet.
// supersedes this load, since legacy and workspace backends emit
// different top-up events and the winning fetch may not carry the
// completion yet.
useTelemetry()?.checkForCompletedTopup(response?.events)

if (loadToken !== latestLoadToken) return

if (response) {
if (response.events) {
events.value = response.events
}

if (response.page) {
pagination.value.page = response.page
}

if (response.limit) {
pagination.value.limit = response.limit
}

if (response.total != null) {
pagination.value.total = response.total
}

if (response.totalPages != null) {
pagination.value.totalPages = response.totalPages
}
} else {
if (!response) {
const legacyError = shouldUseWorkspaceBilling.value
? null
: customerEventService.error.value
error.value = legacyError || t('credits.loadEventsError')
throw new Error(legacyError || t('credits.loadEventsError'))
}

return {
items: response.events ?? [],
page: response.page ?? page,
limit: response.limit ?? requestedLimit,
total: response.total ?? 0
}
} catch (err) {
if (loadToken !== latestLoadToken) return
error.value = t('credits.loadEventsUnknownError')
console.error('Error loading events:', err)
} finally {
if (loadToken === latestLoadToken) loading.value = false
}
}
})

const onPageChange = (event: { page: number }) => {
pagination.value.page = event.page + 1
loadEvents().catch((error) => {
console.error('Error loading events:', error)
const tooltipContentMap = computed(() => {
const map = new Map<string, string>()
events.value.forEach((event) => {
if (customerEventService.hasAdditionalInfo(event) && event.event_id) {
map.set(event.event_id, customerEventService.getTooltipContent(event))
}
})
}
return map
})

const refresh = async () => {
pagination.value.page = 1
await loadEvents()
const onPageChange = (event: { page: number }) => {
goToPage(event.page + 1)
}

watch(
shouldUseWorkspaceBilling,
() => {
refresh().catch((error) => {
console.error('Error loading events:', error)
})
},
{ immediate: true }
)

defineExpose({
refresh
})
</script>
Loading
Loading