From e617ca4c7623dd07e92521bfa89392706487ae0b Mon Sep 17 00:00:00 2001 From: Taras Yemets Date: Wed, 19 Jun 2024 17:49:08 +0300 Subject: [PATCH] chore(plg): migrate invoices list to react-query (#63343) --- client/web/BUILD.bazel | 2 - .../api/hooks/useApiClient.test.tsx | 116 ------------------ .../management/api/hooks/useApiClient.tsx | 73 ----------- .../management/api/react-query/queryKeys.ts | 3 +- .../api/react-query/subscriptions.ts | 10 ++ .../subscription/manage/InvoiceHistory.tsx | 25 +--- .../new/NewCodyProSubscriptionPage.tsx | 15 +-- 7 files changed, 23 insertions(+), 221 deletions(-) delete mode 100644 client/web/src/cody/management/api/hooks/useApiClient.test.tsx delete mode 100644 client/web/src/cody/management/api/hooks/useApiClient.tsx diff --git a/client/web/BUILD.bazel b/client/web/BUILD.bazel index cd7ea82709b..4abc8e96ce1 100644 --- a/client/web/BUILD.bazel +++ b/client/web/BUILD.bazel @@ -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", diff --git a/client/web/src/cody/management/api/hooks/useApiClient.test.tsx b/client/web/src/cody/management/api/hooks/useApiClient.test.tsx deleted file mode 100644 index c56b8bda753..00000000000 --- a/client/web/src/cody/management/api/hooks/useApiClient.test.tsx +++ /dev/null @@ -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 }) => ( - {children} - ) - - 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 = { 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 = { 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 = { 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 = { 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 = { 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() - }) -}) diff --git a/client/web/src/cody/management/api/hooks/useApiClient.tsx b/client/web/src/cody/management/api/hooks/useApiClient.tsx deleted file mode 100644 index d319563654e..00000000000 --- a/client/web/src/cody/management/api/hooks/useApiClient.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useEffect, useState, useContext, useCallback } from 'react' - -import type { Call } from '../client' -import { CodyProApiClientContext } from '../components/CodyProApiClient' - -export interface ReactFriendlyApiResponse { - loading: boolean - error?: Error - data?: T - response?: Response - refetch: () => Promise -} - -// 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(call: Call): ReactFriendlyApiResponse { - const { caller } = useContext(CodyProApiClientContext) - - const [loading, setLoading] = useState(false) - const [error, setError] = useState(undefined) - const [data, setData] = useState(undefined) - const [response, setResponse] = useState(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 } -} diff --git a/client/web/src/cody/management/api/react-query/queryKeys.ts b/client/web/src/cody/management/api/react-query/queryKeys.ts index 3e1f8173a3d..f1949ca3e7d 100644 --- a/client/web/src/cody/management/api/react-query/queryKeys.ts +++ b/client/web/src/cody/management/api/react-query/queryKeys.ts @@ -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, diff --git a/client/web/src/cody/management/api/react-query/subscriptions.ts b/client/web/src/cody/management/api/react-query/subscriptions.ts index 51b519e42a4..95a12cac199 100644 --- a/client/web/src/cody/management/api/react-query/subscriptions.ts +++ b/client/web/src/cody/management/api/react-query/subscriptions.ts @@ -14,6 +14,7 @@ import type { CreateTeamRequest, PreviewResult, PreviewCreateTeamRequest, + GetSubscriptionInvoicesResponse, } from '../teamSubscriptions' import { callCodyProApi } from './callCodyProApi' @@ -37,6 +38,15 @@ export const useSubscriptionSummary = (): UseQueryResult => + useQuery({ + queryKey: queryKeys.subscriptions.subscriptionInvoices(), + queryFn: async () => { + const response = await callCodyProApi(Client.getCurrentSubscriptionInvoices()) + return response.json() + }, + }) + export const useUpdateCurrentSubscription = (): UseMutationResult< Subscription | undefined, Error, diff --git a/client/web/src/cody/management/subscription/manage/InvoiceHistory.tsx b/client/web/src/cody/management/subscription/manage/InvoiceHistory.tsx index ce4c6452207..319837f9725 100644 --- a/client/web/src/cody/management/subscription/manage/InvoiceHistory.tsx +++ b/client/web/src/cody/management/subscription/manage/InvoiceHistory.tsx @@ -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 } - if (error) { + if (isError) { logger.error('Error fetching current subscription invoices', error) return null } - if (response && !response.ok) { - if (response.status === 401) { - return - } - - 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 } diff --git a/client/web/src/cody/management/subscription/new/NewCodyProSubscriptionPage.tsx b/client/web/src/cody/management/subscription/new/NewCodyProSubscriptionPage.tsx index a276996afba..5c7ccaaf0fe 100644 --- a/client/web/src/cody/management/subscription/new/NewCodyProSubscriptionPage.tsx +++ b/client/web/src/cody/management/subscription/new/NewCodyProSubscriptionPage.tsx @@ -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 - - - - - + + + ) }