diff --git a/web/app/src/components/Alert/index.tsx b/web/app/src/components/Alert/index.tsx index 37c827b..7ee5184 100644 --- a/web/app/src/components/Alert/index.tsx +++ b/web/app/src/components/Alert/index.tsx @@ -6,12 +6,16 @@ export type AlertData = { detail: string; } -export default function Alert(props: AlertData) { +type AlertProps = { + alert: AlertData +} + +export default function Alert(props: AlertProps) { return ( - + - {props.title} - {props.detail} + {props.alert.title} + {props.alert.detail} ) } diff --git a/web/app/src/index.tsx b/web/app/src/index.tsx index 90d5870..8e05416 100644 --- a/web/app/src/index.tsx +++ b/web/app/src/index.tsx @@ -2,13 +2,13 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; import reportWebVitals from './reportWebVitals'; -import {BrowserRouter, Route, Routes} from "react-router-dom"; +import {BrowserRouter, Navigate, Route, Routes} from "react-router-dom"; import {ChakraProvider, ColorModeScript} from "@chakra-ui/react"; import Login from "./pages/Login"; import theme from "./theme" import AuthTest from "./pages/AuthTest"; import SignUpPage from "./pages/SignUp"; -import LeagueListPage from "./pages/LeagueList"; +import LeagueListPage from "./pages/Leagues"; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement @@ -23,10 +23,13 @@ root.render( ); function Router() { + return ( - }/> + }/> + }/> + }/> }/> }/> }/> diff --git a/web/app/src/layouts/auth/index.tsx b/web/app/src/layouts/auth/index.tsx index 5443b69..da1cad5 100644 --- a/web/app/src/layouts/auth/index.tsx +++ b/web/app/src/layouts/auth/index.tsx @@ -30,7 +30,7 @@ export default function AuthLayout(props: AuthLayoutProps) { } {props.alert && - + } ([]) - const [alert, setAlert] = useState() - - useEffect(() => { - api.leaguesApi().leaguesGet().then((getLeaguesResponse) => { - setLeagues(getLeaguesResponse.data.leagues) - }).catch((e: AxiosError) => { - try { - const err = api.parseError(e) - console.error(err) - setAlert({ - status: 'error', - title: "Failed to list leagues", - detail: err.detail - }) - } catch (parseError) { - setAlert({ - status: 'error', - title: "Failed to list leagues", - detail: 'Unknown error' - }) - } - }) - }, []) - - function renderList() { - if (alert) { - return ( - - ) - } - - if (leagues.length === 0) { - return ( -

No leagues found, try creating one!

- ) - } - return ( - <> - {leagues.length > 0 && leagues.map((league) => ( - - - {league.name} - - -

{league.location}

-
-
- ))} - - ) - } - - return ( - - - - Leagues - - - - - {renderList()} - - ) -} diff --git a/web/app/src/pages/Leagues/Form.test.tsx b/web/app/src/pages/Leagues/Form.test.tsx new file mode 100644 index 0000000..5cb97ad --- /dev/null +++ b/web/app/src/pages/Leagues/Form.test.tsx @@ -0,0 +1,100 @@ +import {act, render, RenderResult} from "@testing-library/react"; +import LeagueForm from "./Form"; +import {faker} from "@faker-js/faker"; +import {Simulate} from "react-dom/test-utils"; +import {randomInt} from "crypto"; +import * as helpers from '../../helpers' +import {Api, LeaguesApi} from "../../api"; +import {fake} from "../../test"; + +jest.mock('../../api') +const mockApi = jest.mocked(Api) + +const mockedNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom') as any, + useNavigate: () => mockedNavigate, +})); + +beforeEach(() => { + mockedNavigate.mockReset() + mockApi.prototype.leaguesApi.mockReset() + mockApi.prototype.parseError.mockReset() +}) + +describe('LeagueForm', function () { + it('should render', function () { + render( + + ) + }); + it('redirects to /leagues if successful', async function () { + jest.mocked(LeaguesApi).prototype.leaguesPost.mockResolvedValue({ + config: {}, + data: { + league: fake.league() + }, + headers: {}, + request: {}, + status: 201, + statusText: "", + }) + mockApi.prototype.leaguesApi.mockReturnValue(new LeaguesApi()) + + const result = render( + + ) + await typeLeagueInfo(result) + + const submitButton = await result.findByText("Create") + await act(() => { + Simulate.click(submitButton) + }) + + expect(mockedNavigate).toHaveBeenCalledWith('/leagues') + }) + + it('renders error from API', async () => { + const mockError = fake.errorResponse() + mockApi.prototype.parseError.mockReturnValue(mockError) + + jest.mocked(LeaguesApi).prototype.leaguesPost.mockRejectedValue(false) + mockApi.prototype.leaguesApi.mockReturnValue(new LeaguesApi()) + + const result = render( + + ) + await typeLeagueInfo(result) + + const submitButton = await result.findByText("Create") + await act(() => { + Simulate.click(submitButton) + }) + + expect(mockApi.prototype.parseError).toBeCalled() + expect(result.getByText(mockError.title)).toBeInTheDocument() + }) +}); + + +async function typeLeagueInfo(result: RenderResult) { + const name = faker.random.words(randomInt(3, 10)) + + const nameField = await result.findByPlaceholderText("name") + nameField.setAttribute("value", faker.name.fullName()) + await act(() => { + Simulate.change(nameField) + }) + + const slugField = await result.findByPlaceholderText("slug") + slugField.setAttribute("value", helpers.slugify(name)) + await act(() => { + Simulate.change(slugField) + }) + + const locationField = await result.findByPlaceholderText("location") + locationField.setAttribute("value", faker.random.words(4)) + await act(() => { + Simulate.change(locationField) + }) +} diff --git a/web/app/src/pages/Leagues/Form.tsx b/web/app/src/pages/Leagues/Form.tsx new file mode 100644 index 0000000..77e58c2 --- /dev/null +++ b/web/app/src/pages/Leagues/Form.tsx @@ -0,0 +1,154 @@ +import { + Button, + FormControl, + FormErrorMessage, + FormHelperText, + FormLabel, + HStack, + Input, + InputGroup, + Spacer, + Stack +} from "@chakra-ui/react"; +import React, {ReactNode, useState} from "react"; +import {Api, LeagueCreate, useAuth} from "../../api"; +import {slugify} from "../../helpers"; +import {useNavigate} from "react-router-dom"; +import {AxiosError} from "axios"; +import Alert, {AlertData} from "../../components/Alert"; + +export type LeagueFormProps = { + mode: 'create' | 'edit' + onCancel?: () => void +} + +const api = new Api(); + +export default function LeagueForm(props: LeagueFormProps) { + const navigate = useNavigate(); + + useAuth({requireAuth: true}) + + const [alert, setAlert] = useState() + + const [slugModified, setSlugModified] = useState(false) + const [slugError, setSlugError] = useState(undefined) + + const [leagueData, setLeagueData] = useState({ + name: "", + location: "", + slug: "", + }); + + function submitForm() { + api.leaguesApi().leaguesPost(leagueData).then(() => { + // navigate(`/leagues/${response.data.league?.slug}`) + navigate(`/leagues`) + }).catch((e: AxiosError) => { + const err = api.parseError(e) + console.error(err) + setAlert({ + status: "error", + title: err.title, + detail: err.detail + }) + }) + } + + function onNameChange(e: React.ChangeEvent) { + setLeagueData({ + ...leagueData, + name: e.target.value, + ...(slugModified ? {} : { + slug: slugify(e.target.value) + }) + }) + } + + function onSlugChange(e: React.ChangeEvent) { + setSlugError(undefined) + + if (e.target.value === "") { + setSlugModified(false) + setLeagueData({ + ...leagueData, + slug: slugify(leagueData.name) + }) + } else { + setSlugModified(true) + const validSlug = slugify(e.target.value) + + if (validSlug !== e.target.value) { + setSlugError( +

+ Slug must be url-friendly. Try {validSlug} instead, or clear the field to use the generated + slug. +

+ ) + } + + setLeagueData({ + ...leagueData, + slug: e.target.value + }) + } + } + + function onLocationChange(e: React.ChangeEvent) { + setLeagueData({ + ...leagueData, + location: e.target.value + }) + } + + return ( + <> + {alert && + + } + + + League Name + + + + + + Slug + + + + {slugError && + {slugError} + } + + A url-friendly identifier for your league. Use the generated slug or create your own + + + + Location + + + + + The location where the league takes place + + + + + + + {props.onCancel && + + } + + + ) +} diff --git a/web/app/src/pages/LeagueList/index.test.tsx b/web/app/src/pages/Leagues/index.test.tsx similarity index 93% rename from web/app/src/pages/LeagueList/index.test.tsx rename to web/app/src/pages/Leagues/index.test.tsx index e70a04c..81b3d1e 100644 --- a/web/app/src/pages/LeagueList/index.test.tsx +++ b/web/app/src/pages/Leagues/index.test.tsx @@ -1,6 +1,6 @@ import {render} from "@testing-library/react"; import React from "react"; -import LeagueListPage from "./index"; +import LeaguesPage from "./index"; import {Api, LeaguesApi} from "../../api"; import {fake} from "../../test"; import {MemoryRouter} from "react-router-dom"; @@ -20,8 +20,7 @@ beforeEach(() => { mockApi.prototype.parseError.mockReset() }) - -describe('LeagueListPage', () => { +describe('LeaguesPage', () => { it('renders leagues from API', async () => { const mockLeagues = Array.from({length: 5}, () => fake.league()) @@ -39,7 +38,7 @@ describe('LeagueListPage', () => { const result = render( - + ) @@ -49,7 +48,7 @@ describe('LeagueListPage', () => { const leagueCards = await result.findAllByTestId("league-card") expect(leagueCards).toHaveLength(mockLeagues.length) - await mockLeagues.forEach((league) => { + mockLeagues.forEach((league) => { expect(result.getByText(league.name)).toBeInTheDocument() expect(result.getByText(league.location)).toBeInTheDocument() }) @@ -63,7 +62,7 @@ describe('LeagueListPage', () => { const result = render( - + ) @@ -80,7 +79,7 @@ describe('LeagueListPage', () => { const result = render( - + ) @@ -102,7 +101,7 @@ describe('LeagueListPage', () => { const result = render( - + ) diff --git a/web/app/src/pages/Leagues/index.tsx b/web/app/src/pages/Leagues/index.tsx new file mode 100644 index 0000000..5b4c917 --- /dev/null +++ b/web/app/src/pages/Leagues/index.tsx @@ -0,0 +1,126 @@ +import {Api, League, useAuth} from "../../api"; +import {useEffect, useState} from "react"; +import { + Button, + Card, + CardBody, + CardHeader, + Container, + Grid, + GridItem, + Heading, + HStack, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Spacer, +} from "@chakra-ui/react"; +import {useNavigate} from "react-router-dom"; +import {AxiosError} from "axios"; +import Alert, {AlertData} from "../../components/Alert"; +import LeagueForm from "./Form"; + +const api: Api = new Api(); + +export type LeagueListPageProps = { + createFormOpen?: boolean +} + +export default function LeaguesPage(props: LeagueListPageProps) { + const navigate = useNavigate(); + + const auth = useAuth({requireAuth: props.createFormOpen}); + + const [leagues, setLeagues] = useState([]) + const [alert, setAlert] = useState() + + useEffect(() => { + api.leaguesApi().leaguesGet().then((getLeaguesResponse) => { + setLeagues(getLeaguesResponse.data.leagues) + }).catch((e: AxiosError) => { + try { + const err = api.parseError(e) + console.error(err) + setAlert({ + status: 'error', + title: "Failed to list leagues", + detail: err.detail + }) + } catch (parseError) { + setAlert({ + status: 'error', + title: "Failed to list leagues", + detail: 'Unknown error' + }) + } + }) + }, [props.createFormOpen]) + + function renderList() { + if (alert) { + return ( + + ) + } + + if (leagues.length === 0) { + return ( +

No leagues found, try creating one!

+ ) + } + return ( + + {leagues.length > 0 && leagues.map((league) => ( + + + + {league.name} + + +

{league.location}

+
+
+
+ ))} +
+ ) + } + + return ( + <> + navigate('/leagues')}> + + + Create new league + + + navigate('/leagues')}/> + + + + + + + + Leagues + + + {auth?.user && + + } + + {renderList()} + + + ) +}