chore(plg): migrate invoices list to react-query (#63343)

This commit is contained in:
Taras Yemets 2024-06-19 17:49:08 +03:00 committed by GitHub
parent a5a6a0dd23
commit e617ca4c76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 23 additions and 221 deletions

View File

@ -238,7 +238,6 @@ ts_project(
"src/cody/management/UseCodyInEditorSection.tsx",
"src/cody/management/api/client.ts",
"src/cody/management/api/components/CodyProApiClient.ts",
"src/cody/management/api/hooks/useApiClient.tsx",
"src/cody/management/api/react-query/QueryClientProvider.tsx",
"src/cody/management/api/react-query/callCodyProApi.ts",
"src/cody/management/api/react-query/invites.ts",
@ -1919,7 +1918,6 @@ ts_project(
"src/backend/persistenceMapper.test.ts",
"src/codeintel/ReferencesPanel.mocks.ts",
"src/codeintel/ReferencesPanel.test.tsx",
"src/cody/management/api/hooks/useApiClient.test.tsx",
"src/cody/team/TeamMemberList.test.ts",
"src/cody/useCodyIgnore.test.ts",
"src/components/ErrorBoundary.test.tsx",

View File

@ -1,116 +0,0 @@
import { type WrapperComponent, renderHook } from '@testing-library/react-hooks'
import { describe, expect, it, vi } from 'vitest'
import type { Call } from '../client'
import { CodyProApiClientContext } from '../components/CodyProApiClient'
import { useApiCaller } from './useApiClient'
describe('useApiCaller()', () => {
const mockCaller = {
call: vi.fn(),
}
const wrapper: WrapperComponent<{ children: React.ReactNode }> = ({ children }) => (
<CodyProApiClientContext.Provider value={{ caller: mockCaller }}>{children}</CodyProApiClientContext.Provider>
)
it.skip('handles successful API response', async () => {
const mockResponse = { data: { name: 'John Doe' }, response: { ok: true, status: 200 } }
mockCaller.call.mockResolvedValueOnce(mockResponse)
const call: Call<unknown> = { method: 'GET', urlSuffix: '/test' }
const { result, waitForNextUpdate } = renderHook(() => useApiCaller(call), { wrapper })
// validate the initial state
// loading is true because it's called immediately after the hook mounts
expect(result.current.loading).toBe(true)
expect(result.current.error).toBeUndefined()
expect(result.current.data).toBeUndefined()
expect(result.current.response).toBeUndefined()
// wait for promise to resolve
await waitForNextUpdate()
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeUndefined()
expect(result.current.data).toEqual(mockResponse.data)
expect(result.current.response).toEqual(mockResponse.response)
})
it('handles generic API error response', async () => {
const mockResponse = { data: { name: 'John Doe' }, response: { ok: false, status: 500 } }
mockCaller.call.mockResolvedValueOnce(mockResponse)
const call: Call<unknown> = { method: 'GET', urlSuffix: '/test' }
const { result, waitForNextUpdate } = renderHook(() => useApiCaller(call), { wrapper })
// wait for promise to resolve
await waitForNextUpdate()
expect(result.current.loading).toBe(false)
expect(result.current.error?.message).toBe(`unexpected status code: ${mockResponse.response.status}`)
expect(result.current.data).toBeUndefined()
expect(result.current.response).toEqual(mockResponse.response)
})
it('handles 401 API error response', async () => {
const mockResponse = { data: { name: 'John Doe' }, response: { ok: false, status: 401 } }
mockCaller.call.mockResolvedValueOnce(mockResponse)
const call: Call<unknown> = { method: 'GET', urlSuffix: '/test' }
const { result, waitForNextUpdate } = renderHook(() => useApiCaller(call), { wrapper })
// wait for promise to resolve
await waitForNextUpdate()
expect(result.current.loading).toBe(false)
expect(result.current.error?.message).toBe('Please log out and log back in.')
expect(result.current.data).toBeUndefined()
expect(result.current.response).toEqual(mockResponse.response)
})
it('refetches data when refetch is called', async () => {
const mockResponse1 = { data: { name: 'John Doe' }, response: { ok: true, status: 200 } }
const mockResponse2 = { data: { name: 'John Doe' }, response: { ok: true, status: 200 } }
mockCaller.call.mockResolvedValueOnce(mockResponse1).mockResolvedValueOnce(mockResponse2)
const call: Call<unknown> = { method: 'GET', urlSuffix: '/test' }
const { result, waitForNextUpdate } = renderHook(() => useApiCaller(call), { wrapper })
// wait for promise to resolve
await waitForNextUpdate()
// validate the state after the initial API call
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeUndefined()
expect(result.current.data).toEqual(mockResponse1.data)
expect(result.current.response).toEqual(mockResponse1.response)
// call refetch
void result.current.refetch()
// validate loading state
expect(result.current.loading).toBe(true)
// wait for promise to resolve
await waitForNextUpdate()
// validate the state after the refetch
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeUndefined()
expect(result.current.data).toEqual(mockResponse1.data)
expect(result.current.response).toEqual(mockResponse1.response)
})
it('handles network error', async () => {
const errorMessage = 'Random network error'
mockCaller.call.mockRejectedValueOnce(new Error(errorMessage))
const call: Call<unknown> = { method: 'GET', urlSuffix: '/test' }
const { result, waitForNextUpdate } = renderHook(() => useApiCaller(call), { wrapper })
// wait for promise to resolve
await waitForNextUpdate()
expect(result.current.loading).toBe(false)
expect(result.current.error?.message).toBe(errorMessage)
expect(result.current.data).toBeUndefined()
expect(result.current.response).toBeUndefined()
})
})

View File

@ -1,73 +0,0 @@
import { useEffect, useState, useContext, useCallback } from 'react'
import type { Call } from '../client'
import { CodyProApiClientContext } from '../components/CodyProApiClient'
export interface ReactFriendlyApiResponse<T> {
loading: boolean
error?: Error
data?: T
response?: Response
refetch: () => Promise<void>
}
// useApiCaller will issue a REST API call to the backend, returning the
// loading status and the response JSON object and/or error object as React
// state.
//
// IMPORTANT: In order to avoid the same API request being made multiple times,
// you MUST ensure that the provided call is the same between repaints of the
// calling React component. i.e. you pretty much always need to create it via
// `useMemo()`.
export function useApiCaller<Resp>(call: Call<Resp>): ReactFriendlyApiResponse<Resp> {
const { caller } = useContext(CodyProApiClientContext)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | undefined>(undefined)
const [data, setData] = useState<Resp | undefined>(undefined)
const [response, setResponse] = useState<Response | undefined>(undefined)
const callApi = useCallback(async () => {
setLoading(true)
try {
const callerResponse = await caller.call(call)
// If we received a 200 response, all is well. We can just return
// the unmarshalled JSON response object as-is.
if (callerResponse.response.ok) {
setData(callerResponse.data)
setError(undefined)
setResponse(callerResponse.response)
} else {
// For a 4xx or 5xx response this is where we provide any standardized logic for
// error handling. For example:
//
// - On a 401 response, we need to force-logout the user so they can refresh their
// SAMS access token.
// - On a 500 response, perhaps replace the current UI with a full-page error. e.g.
// http://github.com/500 or http://github.com/501
setData(undefined)
setError(new Error(`unexpected status code: ${callerResponse.response.status}`))
setResponse(callerResponse.response)
// Provide a clearer message. A 401 typically comes from the user's SAMS credentials
// having expired on the backend.
if (callerResponse.response.status === 401) {
setError(new Error('Please log out and log back in.'))
}
}
} catch (error) {
setData(undefined)
setError(error)
setResponse(undefined)
} finally {
setLoading(false)
}
}, [call, caller])
useEffect(() => {
void callApi()
}, [callApi])
return { loading, error, data, response, refetch: callApi }
}

View File

@ -4,7 +4,8 @@ export const queryKeys = {
subscriptions: {
all: ['subscription'] as const,
subscription: () => [...queryKeys.subscriptions.all, 'current-subscription'] as const,
subscriptionSummary: () => [...queryKeys.subscriptions.all, 'current-subscription-summary'] as const,
subscriptionSummary: () => [...queryKeys.subscriptions.subscription(), 'current-subscription-summary'] as const,
subscriptionInvoices: () => [...queryKeys.subscriptions.subscription(), 'invoices'] as const,
},
teams: {
all: ['team'] as const,

View File

@ -14,6 +14,7 @@ import type {
CreateTeamRequest,
PreviewResult,
PreviewCreateTeamRequest,
GetSubscriptionInvoicesResponse,
} from '../teamSubscriptions'
import { callCodyProApi } from './callCodyProApi'
@ -37,6 +38,15 @@ export const useSubscriptionSummary = (): UseQueryResult<SubscriptionSummary | u
},
})
export const useSubscriptionInvoices = (): UseQueryResult<GetSubscriptionInvoicesResponse | undefined> =>
useQuery({
queryKey: queryKeys.subscriptions.subscriptionInvoices(),
queryFn: async () => {
const response = await callCodyProApi(Client.getCurrentSubscriptionInvoices())
return response.json()
},
})
export const useUpdateCurrentSubscription = (): UseMutationResult<
Subscription | undefined,
Error,

View File

@ -1,45 +1,30 @@
import { mdiFileDocumentOutline, mdiOpenInNew } from '@mdi/js'
import classNames from 'classnames'
import { Navigate } from 'react-router-dom'
import { logger } from '@sourcegraph/common'
import { H2, Icon, Link, LoadingSpinner, Text } from '@sourcegraph/wildcard'
import { Client } from '../../api/client'
import { useApiCaller } from '../../api/hooks/useApiClient'
import { useSubscriptionInvoices } from '../../api/react-query/subscriptions'
import type { Invoice } from '../../api/teamSubscriptions'
import { humanizeDate, usdCentsToHumanString } from './utils'
import styles from './InvoiceHistory.module.scss'
const invoicesCall = Client.getCurrentSubscriptionInvoices()
export const InvoiceHistory: React.FC = () => {
const { loading, error, data, response } = useApiCaller(invoicesCall)
const { isLoading, isError, error, data } = useSubscriptionInvoices()
if (loading) {
if (isLoading) {
return <LoadingSpinner />
}
if (error) {
if (isError) {
logger.error('Error fetching current subscription invoices', error)
return null
}
if (response && !response.ok) {
if (response.status === 401) {
return <Navigate to="/-/sign-out" replace={true} />
}
logger.error(`Fetch Cody subscription invoices request failed with status ${response.status}`)
return null
}
if (!data) {
if (response) {
logger.error('Current subscription invoices are not available.')
}
logger.error('Current subscription invoices are not available.')
return null
}

View File

@ -25,7 +25,6 @@ import {
import { CodyProRoutes } from '../../../codyProRoutes'
import { PageHeaderIcon } from '../../../components/PageHeaderIcon'
import { USER_CODY_PLAN } from '../../../subscription/queries'
import { defaultCodyProApiClientContext, CodyProApiClientContext } from '../../api/components/CodyProApiClient'
import { useBillingAddressStripeElementsOptions } from '../manage/BillingAddress'
import { CodyProCheckoutForm } from './CodyProCheckoutForm'
@ -87,14 +86,12 @@ const AuthenticatedNewCodyProSubscriptionPage: FunctionComponent<NewCodyProSubsc
</PageHeader.Heading>
</PageHeader>
<CodyProApiClientContext.Provider value={defaultCodyProApiClientContext}>
<Elements stripe={stripe} options={options}>
<CodyProCheckoutForm
initialSeatCount={initialSeatCount}
customerEmail={authenticatedUser?.emails[0].email || ''}
/>
</Elements>
</CodyProApiClientContext.Provider>
<Elements stripe={stripe} options={options}>
<CodyProCheckoutForm
initialSeatCount={initialSeatCount}
customerEmail={authenticatedUser?.emails[0].email || ''}
/>
</Elements>
</Page>
)
}