Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

♻️ Refactor API Calls #715

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
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
38 changes: 37 additions & 1 deletion src/api/Api.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { apiClient, apiHeaders } from './Api'
import { apiClient, apiHeaders, ApiResponse, handleApiCall } from './Api'

describe('apiClient', () => {
beforeEach(() => {
Expand Down Expand Up @@ -31,3 +31,39 @@ describe('apiClient', () => {
expect(fetchMock).toHaveBeenCalledWith('/admin/features', { headers: apiHeaders('token') })
})
})

describe('handleApiCall', async () => {
const onSuccess = vi.fn()
const onError = vi.fn()
const onFailure = vi.fn()

beforeEach(() => {
onSuccess.mockClear()
onError.mockClear()
onFailure.mockClear()
})

it('should call onSuccess on success', async () => {
const apiCall = Promise.resolve({ status: 'success' } as ApiResponse<unknown>)
await handleApiCall(apiCall, { onSuccess, onError, onFailure })
expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onError).not.toHaveBeenCalled()
expect(onFailure).not.toHaveBeenCalled()
})

it('should call onError on error', async () => {
const apiCall = Promise.resolve({ status: 'error' } as ApiResponse<unknown>)
await handleApiCall(apiCall, { onSuccess, onError, onFailure })
expect(onSuccess).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalledTimes(1)
expect(onFailure).not.toHaveBeenCalled()
})

it('should call onFailure on failure', async () => {
const apiCall = Promise.reject(new Error('Network error'))
await handleApiCall(apiCall, { onSuccess, onError, onFailure })
expect(onSuccess).not.toHaveBeenCalled()
expect(onError).not.toHaveBeenCalled()
expect(onFailure).toHaveBeenCalledTimes(1)
})
})
21 changes: 21 additions & 0 deletions src/api/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,24 @@ export function apiHeaders(token: string): HeadersInit {
'Content-Type': 'application/json',
}
}

interface ApiCallParams<T> {
onSuccess: (response: ApiResponse<T>) => void
onError: (response: ApiResponse<T>) => void
onFailure: (error: unknown) => void
}

export async function handleApiCall<T>(apiCall: Promise<ApiResponse<T>>, params: ApiCallParams<T>): Promise<void> {
const { onSuccess, onError, onFailure } = params

try {
const response = await apiCall
if (response.status === 'error') {
onError(response)
} else {
onSuccess(response)
}
} catch (error) {
onFailure(error)
}
}
49 changes: 13 additions & 36 deletions src/components/ticker/BlueskyCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import sign from 'jwt-encode'
import { MemoryRouter } from 'react-router'
import { Ticker } from '../../api/Ticker'
import { AuthProvider } from '../../contexts/AuthContext'
import { NotificationProvider } from '../../contexts/NotificationContext'
import { queryClient, setup, userToken } from '../../tests/utils'
import BlueskyCard from './BlueskyCard'

const token = sign({ id: 1, email: '[email protected]', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret')

describe('BlueSkyCard', () => {
beforeAll(() => {
localStorage.setItem('token', token)
localStorage.setItem('token', userToken)
})

beforeEach(() => {
fetchMock.resetMocks()
})

const ticker = ({ active, connected, handle = '', appKey = '' }: { active: boolean; connected: boolean; handle?: string; appKey?: string }) => {
Expand All @@ -27,41 +25,20 @@ describe('BlueSkyCard', () => {
} as Ticker
}

beforeEach(() => {
fetchMock.resetMocks()
})

function setup(ticker: Ticker) {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={client}>
<MemoryRouter>
<AuthProvider>
<NotificationProvider>
<BlueskyCard ticker={ticker} />
</NotificationProvider>
</AuthProvider>
</MemoryRouter>
</QueryClientProvider>
)
const component = ({ ticker }: { ticker: Ticker }) => {
return <BlueskyCard ticker={ticker} />
}

it('should render the component', () => {
setup(ticker({ active: false, connected: false }))
setup(queryClient, component({ ticker: ticker({ active: false, connected: false }) }))

expect(screen.getByText('Bluesky')).toBeInTheDocument()
expect(screen.getByText('You are not connected with Bluesky.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Configure' })).toBeInTheDocument()
})

it('should render the component when connected and active', async () => {
setup(ticker({ active: true, connected: true, handle: 'handle.bsky.social' }))
setup(queryClient, component({ ticker: ticker({ active: true, connected: true, handle: 'handle.bsky.social' }) }))

expect(screen.getByText('Bluesky')).toBeInTheDocument()
expect(screen.getByText('You are connected with Bluesky.')).toBeInTheDocument()
Expand All @@ -80,7 +57,7 @@ describe('BlueSkyCard', () => {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: 'Bearer ' + token,
Authorization: 'Bearer ' + userToken,
},
method: 'put',
})
Expand All @@ -94,7 +71,7 @@ describe('BlueSkyCard', () => {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: 'Bearer ' + token,
Authorization: 'Bearer ' + userToken,
},
method: 'delete',
})
Expand Down
39 changes: 30 additions & 9 deletions src/components/ticker/BlueskyCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,55 @@ import { faGear, faPause, faPlay, faTrash } from '@fortawesome/free-solid-svg-ic
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Box, Button, Card, CardActions, CardContent, Divider, Link, Stack, Typography } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import { FC, useCallback, useState } from 'react'
import { FC, useState } from 'react'
import { handleApiCall } from '../../api/Api'
import { Ticker, deleteTickerBlueskyApi, putTickerBlueskyApi } from '../../api/Ticker'
import useAuth from '../../contexts/useAuth'
import useNotification from '../../contexts/useNotification'
import BlueskyModalForm from './BlueskyModalForm'

interface Props {
ticker: Ticker
}

const BlueskyCard: FC<Props> = ({ ticker }) => {
const { createNotification } = useNotification()
const { token } = useAuth()
const [open, setOpen] = useState<boolean>(false)

const queryClient = useQueryClient()

const bluesky = ticker.bluesky

const handleDelete = useCallback(() => {
deleteTickerBlueskyApi(token, ticker).finally(() => {
queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] })
const handleDelete = () => {
handleApiCall(deleteTickerBlueskyApi(token, ticker), {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] })
createNotification({ content: 'Bluesky integration successfully deleted', severity: 'success' })
},
onError: () => {
createNotification({ content: 'Failed to delete Bluesky integration', severity: 'error' })
},
onFailure: error => {
createNotification({ content: error as string, severity: 'error' })
},
})
}, [token, queryClient, ticker])
}

const handleToggle = useCallback(() => {
putTickerBlueskyApi(token, { active: !bluesky.active }, ticker).finally(() => {
queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] })
const handleToggle = () => {
handleApiCall(putTickerBlueskyApi(token, { active: !bluesky.active }, ticker), {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] })
createNotification({ content: `Bluesky integration ${bluesky.active ? 'disabled' : 'enabled'} successfully`, severity: 'success' })
},
onError: () => {
createNotification({ content: 'Failed to update Bluesky integration', severity: 'error' })
},
onFailure: error => {
createNotification({ content: error as string, severity: 'error' })
},
})
}, [bluesky.active, token, queryClient, ticker])
}

const profileLink = (
<Link href={'https://bsky.app/profile/' + bluesky.handle} rel="noreferrer" target="_blank">
Expand Down
49 changes: 15 additions & 34 deletions src/components/ticker/BlueskyForm.test.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
import sign from 'jwt-encode'
import { MemoryRouter } from 'react-router'
import { Ticker } from '../../api/Ticker'
import { AuthProvider } from '../../contexts/AuthContext'
import { NotificationProvider } from '../../contexts/NotificationContext'
import { queryClient, setup, userToken } from '../../tests/utils'
import BlueskyForm from './BlueskyForm'

const token = sign({ id: 1, email: '[email protected]', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret')

describe('BlueskyForm', () => {
beforeAll(() => {
localStorage.setItem('token', token)
localStorage.setItem('token', userToken)
})

beforeEach(() => {
fetchMock.resetMocks()
})

const ticker = ({ active, connected, handle = '', appKey = '' }: { active: boolean; connected: boolean; handle?: string; appKey?: string }) => {
Expand All @@ -29,34 +27,17 @@ describe('BlueskyForm', () => {

const callback = vi.fn()

beforeEach(() => {
fetchMock.resetMocks()
})

function setup(ticker: Ticker) {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={client}>
<MemoryRouter>
<AuthProvider>
<NotificationProvider>
<BlueskyForm callback={callback} ticker={ticker} />
<input name="Submit" type="submit" value="Submit" form="configureBluesky" />
</NotificationProvider>
</AuthProvider>
</MemoryRouter>
</QueryClientProvider>
const component = ({ ticker }: { ticker: Ticker }) => {
return (
<>
<BlueskyForm callback={callback} ticker={ticker} />
<input name="Submit" type="submit" value="Submit" form="configureBluesky" />
</>
)
}

it('should render the component', async () => {
setup(ticker({ active: false, connected: false }))
setup(queryClient, component({ ticker: ticker({ active: false, connected: false }) }))

expect(screen.getByText('You need to create a application password in Bluesky.')).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'Active' })).toBeInTheDocument()
Expand All @@ -78,7 +59,7 @@ describe('BlueskyForm', () => {
body: '{"active":true,"handle":"handle.bsky.social","appKey":"password"}',
headers: {
Accept: 'application/json',
Authorization: 'Bearer ' + token,
Authorization: 'Bearer ' + userToken,
'Content-Type': 'application/json',
},
method: 'put',
Expand Down
17 changes: 11 additions & 6 deletions src/components/ticker/BlueskyForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Grid from '@mui/material/Grid2'
import { useQueryClient } from '@tanstack/react-query'
import { FC } from 'react'
import { useForm } from 'react-hook-form'
import { handleApiCall } from '../../api/Api'
import { Ticker, TickerBlueskyFormData, putTickerBlueskyApi } from '../../api/Ticker'
import useAuth from '../../contexts/useAuth'
import useNotification from '../../contexts/useNotification'
Expand Down Expand Up @@ -31,15 +32,19 @@ const BlueskyForm: FC<Props> = ({ callback, ticker }) => {
const queryClient = useQueryClient()

const onSubmit = handleSubmit(data => {
putTickerBlueskyApi(token, data, ticker).then(response => {
if (response.status == 'error') {
setError('root.authenticationFailed', { message: 'Authentication failed' })
createNotification({ content: 'Bluesky integration failed to update', severity: 'error' })
} else {
handleApiCall(putTickerBlueskyApi(token, data, ticker), {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] })
createNotification({ content: 'Bluesky integration was successfully updated', severity: 'success' })
callback()
}
},
onError: () => {
setError('root.authenticationFailed', { message: 'Authentication failed' })
createNotification({ content: 'Failed to update Bluesky integration', severity: 'error' })
},
onFailure: error => {
createNotification({ content: error as string, severity: 'error' })
},
})
})

Expand Down
Loading
Loading