Skip to content

Commit 998a7e6

Browse files
committed
refactor: keep defineExpose(refresh), centralize refresh signal in useBillingContext
Per review feedback: defineExpose('refresh') on UsageLogsTable was a legitimate stateless imperative command (no state to desync), so the refetchKey-prop replacement was the wrong fix for the wrong problem. Reverted UsageLogsTable to expose refresh() again via a template ref, matching this repo's own defineExpose convention (docs/guidance/vue-components.md, src/components/AGENTS.md, .agents/checks/vue-patterns.md). What still needed fixing was where the "should credits UI refresh" decision lived: CreditsPanel had its own local watch(balance) + revision counter, and PlanCreditsPanelContent had none at all (so a top-up while on its Activity tab never refreshed that table). Moved the skip-first-hydration balance-watch into useBillingContext as a new exposed `usageLogsRefreshSignal`, the shared composable that already owns `balance`. Both CreditsPanel and PlanCreditsPanelContent now just watch that one signal and call their own table ref's refresh() - the "when" lives once in the data layer; the "how" (an imperative command) stays where Vue's defineExpose convention says it belongs. usePaginatedQuery's page/limit/total derivation is unchanged - that part was never in dispute.
1 parent c42f753 commit 998a7e6

10 files changed

Lines changed: 98 additions & 81 deletions

File tree

src/components/dialog/content/setting/CreditsPanel.test.ts

Lines changed: 18 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,37 @@
11
import { render, screen } from '@testing-library/vue'
22
import userEvent from '@testing-library/user-event'
33
import { beforeEach, describe, expect, it, vi } from 'vitest'
4-
import { defineComponent, h, nextTick } from 'vue'
4+
import { defineComponent, h } from 'vue'
55
import { createI18n } from 'vue-i18n'
66

7-
import type { BalanceInfo } from '@/composables/billing/types'
8-
97
import CreditsPanel from './CreditsPanel.vue'
108

119
const billingMocks = vi.hoisted(() => ({
12-
balance: { value: null as BalanceInfo | null },
10+
usageLogsRefreshSignal: { value: 0 },
1311
manageSubscription: vi.fn()
1412
}))
1513
vi.mock('@/composables/billing/useBillingContext', async () => {
1614
const { ref } = await import('vue')
17-
const balance = ref<BalanceInfo | null>(null)
18-
Object.defineProperty(billingMocks, 'balance', { get: () => balance })
15+
const usageLogsRefreshSignal = ref(0)
16+
Object.defineProperty(billingMocks, 'usageLogsRefreshSignal', {
17+
get: () => usageLogsRefreshSignal
18+
})
1919
return {
2020
useBillingContext: () => ({
21-
balance,
21+
usageLogsRefreshSignal,
2222
manageSubscription: billingMocks.manageSubscription
2323
})
2424
}
2525
})
2626

27+
const refreshActivity = vi.hoisted(() => vi.fn())
2728
vi.mock('./UsageLogsTable.vue', async () => {
2829
const { defineComponent, h } = await import('vue')
2930
return {
3031
default: defineComponent({
31-
props: { refetchKey: { type: Number, default: 0 } },
32-
setup(props) {
33-
return () =>
34-
h(
35-
'div',
36-
{ 'data-testid': 'usage-logs-table' },
37-
String(props.refetchKey)
38-
)
32+
setup(_props, { expose }) {
33+
expose({ refresh: refreshActivity })
34+
return () => h('div', { 'data-testid': 'usage-logs-table' })
3935
}
4036
})
4137
}
@@ -77,19 +73,10 @@ const i18n = createI18n({
7773
}
7874
})
7975

80-
function makeBalance(amountMicros: number): BalanceInfo {
81-
return {
82-
amountMicros,
83-
currency: 'usd',
84-
effectiveBalanceMicros: amountMicros,
85-
prepaidBalanceMicros: 0,
86-
cloudCreditBalanceMicros: 0
87-
}
88-
}
89-
9076
describe('CreditsPanel', () => {
9177
beforeEach(() => {
92-
billingMocks.balance.value = null
78+
billingMocks.usageLogsRefreshSignal.value = 0
79+
refreshActivity.mockClear()
9380
})
9481

9582
function renderComponent() {
@@ -107,16 +94,12 @@ describe('CreditsPanel', () => {
10794
expect(billingMocks.manageSubscription).toHaveBeenCalledOnce()
10895
})
10996

110-
it('bumps the activity refetch key on a balance change but not on first hydration', async () => {
97+
it('refreshes activity when the shared billing signal changes', async () => {
11198
renderComponent()
112-
const activityTable = screen.getByTestId('usage-logs-table')
113-
114-
billingMocks.balance.value = makeBalance(5000)
115-
await nextTick()
116-
expect(activityTable.textContent).toBe('0')
99+
screen.getByTestId('usage-logs-table')
100+
expect(refreshActivity).not.toHaveBeenCalled()
117101

118-
billingMocks.balance.value = makeBalance(9000)
119-
await nextTick()
120-
expect(activityTable.textContent).toBe('1')
102+
billingMocks.usageLogsRefreshSignal.value++
103+
await vi.waitFor(() => expect(refreshActivity).toHaveBeenCalledOnce())
121104
})
122105
})

src/components/dialog/content/setting/CreditsPanel.vue

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
</Button>
4040
</div>
4141

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

4444
<div v-if="!embedded" class="flex flex-row gap-2">
4545
<Button variant="muted-textonly" @click="handleFaqClick">
@@ -76,19 +76,14 @@ const { embedded = false } = defineProps<{
7676
}>()
7777
7878
const { buildDocsUrl, docsPaths } = useExternalLink()
79-
const { balance, manageSubscription } = useBillingContext()
79+
const { usageLogsRefreshSignal, manageSubscription } = useBillingContext()
8080
const commandStore = useCommandStore()
8181
const telemetry = useTelemetry()
8282
83-
// A genuine balance change (not the first hydration) is a reason for the
84-
// activity table to reload. This is a plain reactive value passed down as a
85-
// prop, rather than the parent reaching into the child to call a method it
86-
// exposes for that purpose.
87-
const creditsRevision = ref(0)
83+
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
8884
89-
watch(balance, (next, previous) => {
90-
if (!next || !previous) return
91-
creditsRevision.value++
85+
watch(usageLogsRefreshSignal, () => {
86+
void usageLogsTableRef.value?.refresh()
9287
})
9388
9489
const handleCreditsHistoryClick = async () => {

src/components/dialog/content/setting/UsageLogsTable.test.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -354,20 +354,6 @@ describe('UsageLogsTable', () => {
354354
limit: 7
355355
})
356356
})
357-
358-
it('refetches page 1 when refetchKey changes', async () => {
359-
const { rerender } = await renderLoaded()
360-
mockCustomerEventsService.getMyEvents.mockClear()
361-
362-
await rerender({ refetchKey: 1 })
363-
364-
await waitFor(() => {
365-
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
366-
page: 1,
367-
limit: 7
368-
})
369-
})
370-
})
371357
})
372358

373359
describe('billing events source', () => {

src/components/dialog/content/setting/UsageLogsTable.vue

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -110,36 +110,23 @@ import {
110110
useCustomerEventsService
111111
} from '@/services/customerEventsService'
112112
113-
const { refetchKey = 0 } = defineProps<{
114-
/**
115-
* Bumped by the parent to signal an out-of-band reason to reload (e.g. a
116-
* balance change) without reaching into this component's internals.
117-
*/
118-
refetchKey?: number
119-
}>()
120-
121113
const { t } = useI18n()
122114
123115
const customerEventService = useCustomerEventsService()
124116
125117
const { shouldUseWorkspaceBilling } = useBillingRouting()
126118
127-
// A billing-rail flip or a parent-signalled refetch are both reasons to
128-
// reset to page 1 and reload against the (possibly new) backend.
129-
const queryKey = computed(
130-
() => `${shouldUseWorkspaceBilling.value}:${refetchKey}`
131-
)
132-
133119
const {
134120
items: events,
135121
limit,
136122
total,
137123
first,
138124
loading,
139125
error,
140-
goToPage
141-
} = usePaginatedQuery<AuditLog, string>({
142-
key: queryKey,
126+
goToPage,
127+
refresh
128+
} = usePaginatedQuery<AuditLog, boolean>({
129+
key: shouldUseWorkspaceBilling,
143130
initialLimit: 7,
144131
fetchPage: async ({ page, limit: requestedLimit }) => {
145132
const params = { page, limit: requestedLimit }
@@ -189,4 +176,8 @@ const tooltipContentMap = computed(() => {
189176
const onPageChange = (event: { page: number }) => {
190177
goToPage(event.page + 1)
191178
}
179+
180+
defineExpose({
181+
refresh
182+
})
192183
</script>

src/composables/billing/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ export interface BillingState {
123123
export interface BillingContext extends BillingState, BillingActions {
124124
type: ComputedRef<BillingType>
125125
reconcileSubscriptionSuccess: () => Promise<void>
126+
/**
127+
* Bumped whenever `balance` genuinely changes (not on its initial
128+
* hydration) — the one signal for "credits-derived UI, like the usage
129+
* log, should reload," so consumers watch this instead of each
130+
* re-deriving the same balance-transition check.
131+
*/
132+
usageLogsRefreshSignal: Ref<number>
126133
/**
127134
* True when the active team workspace is still on a pre-credit-slider
128135
* (legacy) per-member tier plan, which keeps the old team pricing table.

src/composables/billing/useBillingContext.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,17 @@ describe('useBillingContext', () => {
282282
await expect(fetchBalance()).resolves.toBeUndefined()
283283
})
284284

285+
it('bumps usageLogsRefreshSignal on a genuine balance change but not on first hydration', async () => {
286+
const context = useBillingContext()
287+
288+
await vi.waitFor(() => expect(context.balance.value).not.toBeNull())
289+
expect(context.usageLogsRefreshSignal.value).toBe(0)
290+
291+
await context.fetchBalance()
292+
293+
expect(context.usageLogsRefreshSignal.value).toBe(1)
294+
})
295+
285296
it('exposes subscribe action', async () => {
286297
const { subscribe } = useBillingContext()
287298
await expect(subscribe('pro-monthly')).resolves.toEqual({

src/composables/billing/useBillingContext.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,12 @@ function useBillingContextInternal(): BillingContext {
124124
toValue(activeContext.value.balance)
125125
)
126126

127+
const usageLogsRefreshSignal = ref(0)
128+
watch(balance, (next, previous) => {
129+
if (!next || !previous) return
130+
usageLogsRefreshSignal.value++
131+
})
132+
127133
const plans = computed(() => toValue(checkoutContext.value.plans))
128134

129135
const currentPlanSlug = computed(() =>
@@ -348,6 +354,7 @@ function useBillingContextInternal(): BillingContext {
348354
isInitialized,
349355
subscription,
350356
balance,
357+
usageLogsRefreshSignal,
351358
plans,
352359
currentPlanSlug,
353360
teamCreditStops,

src/platform/workspace/components/dialogs/settings/PlanCreditsPanelContent.test.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import userEvent from '@testing-library/user-event'
22
import { describe, expect, it, vi } from 'vitest'
3+
import { defineComponent, h } from 'vue'
34
import { createI18n } from 'vue-i18n'
45

56
import { render, screen } from '@testing-library/vue'
@@ -15,6 +16,22 @@ vi.mock('@/platform/distribution/types', () => ({
1516
}
1617
}))
1718

19+
const billingMocks = vi.hoisted(() => ({
20+
usageLogsRefreshSignal: { value: 0 }
21+
}))
22+
vi.mock('@/composables/billing/useBillingContext', async () => {
23+
const { ref } = await import('vue')
24+
const usageLogsRefreshSignal = ref(0)
25+
Object.defineProperty(billingMocks, 'usageLogsRefreshSignal', {
26+
get: () => usageLogsRefreshSignal
27+
})
28+
return {
29+
useBillingContext: () => ({ usageLogsRefreshSignal })
30+
}
31+
})
32+
33+
const refreshUsageLogs = vi.hoisted(() => vi.fn())
34+
1835
const stubs = {
1936
SubscriptionPanelContentWorkspace: {
2037
template: '<section aria-label="Plan and credits overview" />'
@@ -26,9 +43,12 @@ const stubs = {
2643
SubscriptionFooterLinks: {
2744
template: '<footer aria-label="Subscription links" />'
2845
},
29-
UsageLogsTable: {
30-
template: '<section aria-label="Usage logs" />'
31-
}
46+
UsageLogsTable: defineComponent({
47+
setup(_props, { expose }) {
48+
expose({ refresh: refreshUsageLogs })
49+
return () => h('section', { 'aria-label': 'Usage logs' })
50+
}
51+
})
3252
}
3353

3454
function renderPanel({ cloud = true } = {}) {
@@ -75,4 +95,13 @@ describe('PlanCreditsPanelContent', () => {
7595
screen.queryByRole('region', { name: 'Plan and credits overview' })
7696
).toBeNull()
7797
})
98+
99+
it('refreshes the usage log when the shared billing signal changes', async () => {
100+
renderPanel()
101+
await userEvent.click(screen.getByRole('button', { name: 'Activity' }))
102+
refreshUsageLogs.mockClear()
103+
104+
billingMocks.usageLogsRefreshSignal.value++
105+
await vi.waitFor(() => expect(refreshUsageLogs).toHaveBeenCalledOnce())
106+
})
78107
})

src/platform/workspace/components/dialogs/settings/PlanCreditsPanelContent.vue

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,29 +27,36 @@
2727
/>
2828
</div>
2929
</template>
30-
<UsageLogsTable v-else />
30+
<UsageLogsTable v-else ref="usageLogsTable" />
3131
</div>
3232
</template>
3333

3434
<script setup lang="ts">
35-
import { computed, ref } from 'vue'
35+
import { computed, ref, useTemplateRef, watch } from 'vue'
3636
import { useI18n } from 'vue-i18n'
3737
3838
import CreditsPanel from '@/components/dialog/content/setting/CreditsPanel.vue'
3939
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
4040
import Button from '@/components/ui/button/Button.vue'
41+
import { useBillingContext } from '@/composables/billing/useBillingContext'
4142
import SubscriptionFooterLinks from '@/platform/cloud/subscription/components/SubscriptionFooterLinks.vue'
4243
import { isCloud } from '@/platform/distribution/types'
4344
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
4445
4546
type View = 'overview' | 'activity'
4647
4748
const { t } = useI18n()
49+
const { usageLogsRefreshSignal } = useBillingContext()
4850
4951
const tabs = computed<{ key: View; label: string }[]>(() => [
5052
{ key: 'overview', label: t('workspacePanel.planCredits.tabs.overview') },
5153
{ key: 'activity', label: t('workspacePanel.planCredits.tabs.activity') }
5254
])
5355
5456
const activeView = ref<View>('overview')
57+
58+
const usageLogsTable = useTemplateRef('usageLogsTable')
59+
watch(usageLogsRefreshSignal, () => {
60+
void usageLogsTable.value?.refresh()
61+
})
5562
</script>

src/storybook/mocks/useBillingContext.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export function useBillingContext(): BillingContext {
5252
isInitialized: ref(true),
5353
subscription: computed(() => state.value.subscription),
5454
balance: computed(() => null),
55+
usageLogsRefreshSignal: ref(0),
5556
plans: computed(() => []),
5657
currentPlanSlug: computed(() => null),
5758
teamCreditStops: computed(() => null),

0 commit comments

Comments
 (0)