diff --git a/src/api/Ticker.ts b/src/api/Ticker.ts index 6234464..d878d4b 100644 --- a/src/api/Ticker.ts +++ b/src/api/Ticker.ts @@ -91,8 +91,8 @@ export interface GetTickersQueryParams { active?: boolean domain?: string title?: string - order_by: string - sort: SortDirection + order_by?: string + sort?: SortDirection } export function useTickerApi(token: string) { @@ -134,10 +134,6 @@ export function useTickerApi(token: string) { return fetch(`${ApiUrl}/admin/tickers?${query}`, { headers: headers }).then(response => response.json()) } - const getTicker = (id: number): Promise> => { - return fetch(`${ApiUrl}/admin/tickers/${id}`, { headers: headers }).then(response => response.json()) - } - const postTicker = (data: Ticker): Promise> => { return fetch(`${ApiUrl}/admin/tickers`, { headers: headers, @@ -218,7 +214,6 @@ export function useTickerApi(token: string) { deleteTicker, deleteTickerUser, getTickers, - getTicker, getTickerUsers, postTicker, putTicker, diff --git a/src/components/ticker/Ticker.tsx b/src/components/ticker/Ticker.tsx index 87b36c3..ec3c26f 100644 --- a/src/components/ticker/Ticker.tsx +++ b/src/components/ticker/Ticker.tsx @@ -1,48 +1,67 @@ +import { faGear } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { Alert, Box, Button, Card, CardContent, Grid, Stack, Typography } from '@mui/material' import { FC, useState } from 'react' import { Ticker as Model } from '../../api/Ticker' +import useAuth from '../../contexts/useAuth' +import Loader from '../Loader' import MessageForm from '../message/MessageForm' -import TickerCard from './TickerCard' import MessageList from '../message/MessageList' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faGear } from '@fortawesome/free-solid-svg-icons' -import { Alert, Box, Button, Card, CardContent, Grid, Stack, Typography } from '@mui/material' -import TickerModalForm from './TickerModalForm' +import TickerCard from './TickerCard' import TickerDangerZoneCard from './TickerDangerZoneCard' +import TickerModalForm from './TickerModalForm' import TickerUsersCard from './TickerUsersCard' -import useAuth from '../../contexts/useAuth' interface Props { - ticker: Model + ticker?: Model + isLoading: boolean } -const Ticker: FC = ({ ticker }) => { +const Ticker: FC = ({ ticker, isLoading }) => { const { user } = useAuth() const [formModalOpen, setFormModalOpen] = useState(false) + const headline = () => ( + + + Ticker + + + { + setFormModalOpen(false) + }} + open={formModalOpen} + ticker={ticker} + /> + + ) + + if (ticker === undefined || isLoading) { + return ( + + + {headline()} + + + + + + ) + } + return ( - - - Ticker - - - { - setFormModalOpen(false) - }} - open={formModalOpen} - ticker={ticker} - /> - + {headline()} {!ticker.active ? ( diff --git a/src/components/ticker/TickerList.test.tsx b/src/components/ticker/TickerList.test.tsx index 23226dd..eaab657 100644 --- a/src/components/ticker/TickerList.test.tsx +++ b/src/components/ticker/TickerList.test.tsx @@ -27,7 +27,7 @@ describe('TickerList', function () { - + @@ -44,6 +44,6 @@ describe('TickerList', function () { setup() - expect(TickerListItems).toHaveBeenCalledTimes(4) + expect(TickerListItems).toHaveBeenCalledTimes(3) }) }) diff --git a/src/components/ticker/TickerList.tsx b/src/components/ticker/TickerList.tsx index 7330af1..9ad5789 100644 --- a/src/components/ticker/TickerList.tsx +++ b/src/components/ticker/TickerList.tsx @@ -1,15 +1,18 @@ -import { FC, useEffect, useState } from 'react' -import TickerListItems from './TickerListItems' import { Table, TableCell, TableContainer, TableHead, TableRow, TableSortLabel } from '@mui/material' -import useDebounce from '../../hooks/useDebounce' +import { FC, useEffect, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { GetTickersQueryParams } from '../../api/Ticker' +import useDebounce from '../../hooks/useDebounce' import TickerListFilter from './TickerListFilter' +import TickerListItems from './TickerListItems' + +interface Props { + token: string +} -const TickerList: FC = () => { - const initialState = { order_by: 'id', sort: 'asc' } as GetTickersQueryParams - const [params, setParams] = useState(initialState) - const debouncedValue = useDebounce(params, 200, initialState) +const TickerList: FC = ({ token }) => { + const [params, setParams] = useState({}) + const debouncedValue = useDebounce(params, 200, {}) const [, setSearchParams] = useSearchParams() useEffect(() => { @@ -88,7 +91,7 @@ const TickerList: FC = () => { - + ) diff --git a/src/components/ticker/TickerListItems.test.tsx b/src/components/ticker/TickerListItems.test.tsx index 78ad53b..2064298 100644 --- a/src/components/ticker/TickerListItems.test.tsx +++ b/src/components/ticker/TickerListItems.test.tsx @@ -1,10 +1,10 @@ -import { render, screen } from '@testing-library/react' -import { GetTickersQueryParams } from '../../api/Ticker' -import TickerListItems from './TickerListItems' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import sign from 'jwt-encode' import { MemoryRouter } from 'react-router' +import { GetTickersQueryParams } from '../../api/Ticker' import { AuthProvider } from '../../contexts/AuthContext' -import sign from 'jwt-encode' +import TickerListItems from './TickerListItems' describe('TickerListItems', function () { beforeEach(() => { @@ -35,7 +35,7 @@ describe('TickerListItems', function () { - + diff --git a/src/components/ticker/TickerListItems.tsx b/src/components/ticker/TickerListItems.tsx index 62563d9..47cd159 100644 --- a/src/components/ticker/TickerListItems.tsx +++ b/src/components/ticker/TickerListItems.tsx @@ -1,25 +1,19 @@ import { CircularProgress, Stack, TableBody, TableCell, TableRow, Typography } from '@mui/material' import { FC } from 'react' -import { GetTickersQueryParams, useTickerApi } from '../../api/Ticker' +import { GetTickersQueryParams } from '../../api/Ticker' +import useTickersQuery from '../../queries/tickers' import TickerListItem from './TickerListItem' -import useAuth from '../../contexts/useAuth' -import { useQuery } from '@tanstack/react-query' -import { Navigate } from 'react-router' interface Props { + token: string params: GetTickersQueryParams } -const TickerListItems: FC = ({ params }: Props) => { - const { token, user } = useAuth() - const { getTickers } = useTickerApi(token) - const { isFetching, error, data } = useQuery({ - queryKey: ['tickers', params], - queryFn: () => getTickers(params), - placeholderData: previousData => previousData, - }) +const TickerListItems: FC = ({ token, params }) => { + const { data, isLoading, error } = useTickersQuery({ token, params: params }) + const tickers = data?.data.tickers || [] - if (data === undefined && isFetching) { + if (isLoading) { return ( @@ -36,7 +30,7 @@ const TickerListItems: FC = ({ params }: Props) => { ) } - if (error || data === undefined || data.status === 'error') { + if (error) { return ( @@ -48,7 +42,7 @@ const TickerListItems: FC = ({ params }: Props) => { ) } - if (data.status === 'success' && data.data.tickers.length === 0) { + if (tickers.length === 0) { return ( @@ -62,12 +56,6 @@ const TickerListItems: FC = ({ params }: Props) => { ) } - const tickers = data.data.tickers - - if (tickers.length === 1 && !user?.roles.includes('admin')) { - return - } - return ( {tickers.map(ticker => ( diff --git a/src/queries/tickers.tsx b/src/queries/tickers.tsx new file mode 100644 index 0000000..9ef0621 --- /dev/null +++ b/src/queries/tickers.tsx @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query' +import { GetTickersQueryParams, useTickerApi } from '../api/Ticker' + +interface Props { + token: string + params: GetTickersQueryParams +} + +const useTickersQuery = ({ token, params }: Props) => { + const { getTickers } = useTickerApi(token) + + return useQuery({ + queryKey: ['tickers', params], + queryFn: () => getTickers(params), + placeholderData: previousData => previousData, + }) +} + +export default useTickersQuery diff --git a/src/views/HomeView.test.tsx b/src/views/HomeView.test.tsx new file mode 100644 index 0000000..1c079db --- /dev/null +++ b/src/views/HomeView.test.tsx @@ -0,0 +1,93 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import sign from 'jwt-encode' +import { MemoryRouter } from 'react-router' +import { AuthProvider } from '../contexts/AuthContext' +import HomeView from './HomeView' + +describe('HomeView', () => { + function setup() { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + return render( + + + + + + + + ) + } + + it('should render error when query fails', async () => { + fetchMock.mockReject(new Error('Failed to fetch')) + + const token = sign( + { + id: 1, + email: 'louis@systemli.org', + roles: ['user', 'admin'], + exp: new Date().getTime() / 1000 + 600, + }, + 'secret' + ) + vi.spyOn(window.localStorage.__proto__, 'getItem').mockReturnValue(token) + + setup() + + expect(fetchMock).toHaveBeenCalledTimes(1) + + expect(screen.getByText(/loading/i)).toBeInTheDocument() + expect(await screen.findByText('Unable to fetch tickers from server.')).toBeInTheDocument() + }) + + it('should render tickers list', async () => { + fetchMock.mockResponseOnce(JSON.stringify({ data: { tickers: [] } })) + + const token = sign( + { + id: 1, + email: 'louis@systemli.org', + roles: ['user', 'admin'], + exp: new Date().getTime() / 1000 + 600, + }, + 'secret' + ) + + vi.spyOn(window.localStorage.__proto__, 'getItem').mockReturnValue(token) + + setup() + + expect(fetchMock).toHaveBeenCalledTimes(2) + + expect(screen.getByText(/loading/i)).toBeInTheDocument() + expect(await screen.findByText('Tickers')).toBeInTheDocument() + }) + + it('should redirect to ticker when user has only one ticker', async () => { + fetchMock.mockResponseOnce(JSON.stringify({ data: { tickers: [{ id: 1 }] } })) + + const token = sign( + { + id: 1, + email: 'louis@systemli.org', + roles: ['user'], + exp: new Date().getTime() / 1000 + 600, + }, + 'secret' + ) + + vi.spyOn(window.localStorage.__proto__, 'getItem').mockReturnValue(token) + + setup() + + expect(screen.getByText(/loading/i)).toBeInTheDocument() + }) +}) diff --git a/src/views/HomeView.tsx b/src/views/HomeView.tsx index 5cba53a..70b7c48 100644 --- a/src/views/HomeView.tsx +++ b/src/views/HomeView.tsx @@ -1,53 +1,52 @@ -import { FC, useState } from 'react' -import TickerList from '../components/ticker/TickerList' +import { FC } from 'react' +import { Navigate } from 'react-router' +import { useSearchParams } from 'react-router-dom' +import Loader from '../components/Loader' import useAuth from '../contexts/useAuth' +import useTickersQuery from '../queries/tickers' +import ErrorView from './ErrorView' import Layout from './Layout' -import { Button, Card, Grid, Stack, Typography } from '@mui/material' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faPlus } from '@fortawesome/free-solid-svg-icons' -import TickerModalForm from '../components/ticker/TickerModalForm' +import TickerListView from './TickerListView' const HomeView: FC = () => { - const { user } = useAuth() - const [formModalOpen, setFormModalOpen] = useState(false) + const { token, user } = useAuth() + const [params] = useSearchParams() + const { data, error, isLoading } = useTickersQuery({ + token, + params: { + order_by: params.get('order_by') ?? 'id', + sort: params.get('sort') === 'asc' ? 'asc' : 'desc', + title: params.get('title') ?? undefined, + domain: params.get('domain') ?? undefined, + active: params.get('active') === 'true' ? true : params.get('active') === 'false' ? false : undefined, + }, + }) - return ( - - - - - - Tickers - - {user?.roles.includes('admin') ? ( - <> - - { - setFormModalOpen(false) - }} - open={formModalOpen} - /> - - ) : null} - - - - - - - - - - ) + if (isLoading) { + return ( + + + + ) + } + + if (error) { + return ( + + +

Unable to fetch tickers from server.

+
+
+ ) + } + + const tickers = data?.data.tickers || [] + + if (!user?.roles.includes('admin') && tickers.length === 1) { + return + } + + return } export default HomeView diff --git a/src/views/TickerListView.tsx b/src/views/TickerListView.tsx new file mode 100644 index 0000000..714770b --- /dev/null +++ b/src/views/TickerListView.tsx @@ -0,0 +1,53 @@ +import { faPlus } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { Button, Card, Grid, Stack, Typography } from '@mui/material' +import { FC, useState } from 'react' +import TickerList from '../components/ticker/TickerList' +import TickerModalForm from '../components/ticker/TickerModalForm' +import useAuth from '../contexts/useAuth' +import Layout from './Layout' + +const TickerListView: FC = () => { + const { token, user } = useAuth() + const [formModalOpen, setFormModalOpen] = useState(false) + + return ( + + + + + + Tickers + + {user?.roles.includes('admin') ? ( + <> + + { + setFormModalOpen(false) + }} + open={formModalOpen} + /> + + ) : null} + + + + + + + + + + ) +} + +export default TickerListView diff --git a/src/views/TickerView.test.tsx b/src/views/TickerView.test.tsx index eab1ff4..b613cf1 100644 --- a/src/views/TickerView.test.tsx +++ b/src/views/TickerView.test.tsx @@ -1,61 +1,13 @@ -import sign from 'jwt-encode' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen } from '@testing-library/react' +import sign from 'jwt-encode' import { MemoryRouter, Route, Routes } from 'react-router' +import { vi } from 'vitest' +import ProtectedRoute from '../components/ProtectedRoute' import { AuthProvider } from '../contexts/AuthContext' import TickerView from './TickerView' -import ProtectedRoute from '../components/ProtectedRoute' -import { vi } from 'vitest' describe('TickerView', function () { - const jwt = sign( - { - id: 1, - email: 'louis@systemli.org', - roles: ['user'], - exp: new Date().getTime() / 1000 + 600, - }, - 'secret' - ) - - const tickerResponse = JSON.stringify({ - data: { - ticker: { - id: 1, - createdAt: new Date(), - domain: 'localhost', - title: 'Ticker Title', - description: 'Description', - active: true, - information: {}, - mastodon: {}, - twitter: {}, - telegram: {}, - bluesky: {}, - location: {}, - }, - }, - }) - const messagesResponse = JSON.stringify({ - data: { - messages: [ - { - id: 1, - ticker: 1, - text: 'Message', - createdAt: new Date(), - geoInformation: '{"type":"FeatureCollection","features":[]}', - attachments: [], - }, - ], - }, - }) - - beforeEach(() => { - vi.spyOn(window.localStorage.__proto__, 'getItem').mockReturnValue(jwt) - fetch.resetMocks() - }) - function setup() { const client = new QueryClient({ defaultOptions: { @@ -77,32 +29,84 @@ describe('TickerView', function () { ) } - test('renders a ticker with messages', async function () { - fetch.mockIf(/^http:\/\/localhost:8080\/.*$/, (request: Request) => { - if (request.url.endsWith('/admin/tickers/1')) { - return Promise.resolve(tickerResponse) - } - if (request.url.endsWith('/admin/tickers/1/messages?limit=10')) { - return Promise.resolve(messagesResponse) - } - - return Promise.resolve( - JSON.stringify({ - data: [], - status: 'error', - error: 'error message', - }) + beforeEach(() => { + vi.spyOn(window.localStorage.__proto__, 'getItem').mockReturnValue( + sign( + { + id: 1, + email: 'louis@systemli.org', + roles: ['user'], + exp: new Date().getTime() / 1000 + 600, + }, + 'secret' ) - }) + ) + fetchMock.resetMocks() + }) + + it('should render error when query fails', async function () { + fetchMock.mockReject(new Error('Failed to fetch')) setup() + expect(fetchMock).toHaveBeenCalledTimes(1) + + expect(screen.getByText(/loading/i)).toBeInTheDocument() + expect(await screen.findByText('Ticker not found.')).toBeInTheDocument() + }) + + it('should render the ticker', async function () { + fetchMock.doMockOnceIf( + /v1\/admin\/tickers/, + JSON.stringify({ + data: { + tickers: [ + { + id: 1, + createdAt: new Date(), + domain: 'localhost', + title: 'Ticker Title', + description: 'Description', + active: true, + information: {}, + mastodon: {}, + twitter: {}, + telegram: {}, + bluesky: {}, + location: {}, + }, + ], + }, + status: 'success', + }) + ) + fetchMock.doMockOnceIf( + /v1\/admin\/messages/, + JSON.stringify({ + data: { + messages: [ + { + id: 1, + ticker: 1, + text: 'Message', + createdAt: new Date(), + geoInformation: '{"type":"FeatureCollection","features":[]}', + attachments: [], + }, + ], + }, + status: 'success', + }) + ) + + setup() + + expect(fetchMock).toHaveBeenCalledTimes(1) + // Loader for the Ticker expect(screen.getByText(/loading/i)).toBeInTheDocument() expect(await screen.findByText('Ticker Title')).toBeInTheDocument() - // Loader for the Messages - expect(screen.getByText(/loading/i)).toBeInTheDocument() - expect(await screen.findByText('Message')).toBeInTheDocument() + expect(fetchMock).toHaveBeenCalledTimes(2) }) }) diff --git a/src/views/TickerView.tsx b/src/views/TickerView.tsx index 0d8c41a..1a901d5 100644 --- a/src/views/TickerView.tsx +++ b/src/views/TickerView.tsx @@ -1,41 +1,37 @@ import { FC } from 'react' -import { useTickerApi } from '../api/Ticker' -import { useQuery } from '@tanstack/react-query' import { useParams } from 'react-router-dom' -import useAuth from '../contexts/useAuth' import Ticker from '../components/ticker/Ticker' -import Layout from './Layout' +import useAuth from '../contexts/useAuth' +import useTickersQuery from '../queries/tickers' import ErrorView from './ErrorView' -import Loader from '../components/Loader' +import Layout from './Layout' interface TickerViewParams { tickerId: string } const TickerView: FC = () => { - const { token } = useAuth() - const { getTicker } = useTickerApi(token) const { tickerId } = useParams() as TickerViewParams const tickerIdNum = parseInt(tickerId) - - const { isLoading, error, data } = useQuery({ - queryKey: ['ticker', tickerIdNum], - queryFn: () => getTicker(tickerIdNum), - }) - - if (isLoading) { - return - } - - if (error || data === undefined || data.status === 'error') { - return Unable to fetch the ticker from server. + const { token } = useAuth() + const { data, isLoading, error } = useTickersQuery({ token: token, params: {} }) + + if (error !== null || data?.status === 'error') { + return ( + + +

Ticker not found.

+
+
+ ) } - const ticker = data.data.ticker + const tickers = data?.data.tickers || [] + const ticker = tickers.find(ticker => ticker.id === tickerIdNum) return ( - + ) }