mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 17:31:43 +00:00
chore(plg): migrate invoices list to react-query (#63343)
This commit is contained in:
parent
a5a6a0dd23
commit
e617ca4c76
@ -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",
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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 }
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user