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

[WIP] Add login screen #8

Open
wants to merge 11 commits into
base: master
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
6 changes: 6 additions & 0 deletions react-ts/.husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

cd react-ts

yarn lint-check && yarn prettier-check && CI=true yarn test
4 changes: 3 additions & 1 deletion react-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"eject": "react-scripts eject",
"prettier-format": "prettier --write './**/*.{js,jsx,ts,tsx,css,md,json}'",
"prettier-check": "prettier --check './**/*.{js,jsx,ts,tsx,css,md,json}'",
"lint-check": "eslint -c .eslintrc.json --max-warnings=0 src"
"lint-check": "./node_modules/.bin/eslint -c .eslintrc.json --max-warnings=0 src",
"prepare": "cd .. && husky install react-ts/.husky"
},
"dependencies": {
"react": "^18.2.0",
Expand All @@ -27,6 +28,7 @@
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-require-explicit-generics": "^0.4.2",
"husky": "^8.0.3",
"prettier": "3.0.1",
"react-scripts": "5.0.1",
"typescript": "v4.8.4"
Expand Down
4 changes: 3 additions & 1 deletion react-ts/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<title>Ordering system 3000</title>

<link rel="stylesheet" href="https://fonts.bunny.net/css?family=Poppins" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
Expand Down
25 changes: 25 additions & 0 deletions react-ts/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from "react";
import { render } from "@testing-library/react";

import { AppState, OrderType } from "./state";
import { getHeading, WrappedApp } from "./testUtils";
import { HomePageRoute } from "./routes";

test("visiting invalid path returns our 404 page", () => {
render(<WrappedApp initialPath="/nono" />);

expect(getHeading().textContent).toMatch("Tudy cesta nevede");
});

test("visiting the homepage redirects to the order type selector if no order type selected", () => {
render(<WrappedApp initialPath={HomePageRoute.path} />);

expect(getHeading().textContent).toMatch("Objednávkový systém");
});

test("visiting the homepage stays if order type selected", () => {
const state: AppState = { orderType: OrderType.PRESCRIPTION };
render(<WrappedApp initialPath={HomePageRoute.path} initialState={state} />);

expect(getHeading().textContent).toMatch("už to sviští");
});
18 changes: 17 additions & 1 deletion react-ts/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import React from "react";
import { Routes, Route } from "react-router-dom";

import "./styles/variables.css";
import "./styles/main.css";
import "./styles/components/design/PageContent.css";
import "./styles/components/design/FancyRadio.css";
import "./styles/components/OrderTypeForm.css";
import "./styles/components/CatEmoji.css";

import { routes } from "./routes";

function App() {
return <>Add your code here :)</>;
return (
<Routes>
{routes.map((route) => (
<Route key={route.path} path={route.path} element={route.element} />
))}
</Routes>
);
}

export default App;
6 changes: 6 additions & 0 deletions react-ts/src/components/CatEmoji.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const catEmojis = ["🐱", "🐱", "😺", "😽", "😻"];
const getRandomCatEmoji = () => catEmojis[Math.floor(Math.random() * catEmojis.length)];

export function CatEmoji() {
return <p className="CatEmoji">{getRandomCatEmoji()}</p>;
}
88 changes: 88 additions & 0 deletions react-ts/src/components/OrderTypeForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { OrderTypeForm } from "./OrderTypeForm";
import { OrderType } from "../state";

// TODO: This checks the input[type="radio"],
// but since we use the FancyRadio, the actual visuals might differ,
// so we should check the if the corresponding label has the proper --selected class,
// because that is what the user sees.
// This could/should be covered (also) by a FancyRadio unit test.
function getSelectedOrderType(): OrderType {
const radio: HTMLInputElement = screen.getByRole("radio", { checked: true });
return radio.value as OrderType;
}

function getCtaButton() {
return screen.getByRole<HTMLButtonElement>("button"); // , { name: /K objednání/i }
}

// array of [initialOrderType, selectingOptionByText, expectingSelectedOrderType]
const flowData: [OrderType, string | null, OrderType][] = [
[OrderType.PRESCRIPTION, "objednat na vyšetření", OrderType.SCREENING],
[OrderType.SCREENING, "objednat na vyšetření", OrderType.SCREENING],
[OrderType.SCREENING, null, OrderType.SCREENING],
[OrderType.PRESCRIPTION, "objednat recept", OrderType.PRESCRIPTION],
[OrderType.SCREENING, "objednat recept", OrderType.PRESCRIPTION],
[OrderType.PRESCRIPTION, null, OrderType.PRESCRIPTION],
];

const buttonLabel = {
[OrderType.SCREENING]: "K objednání vyšetření",
[OrderType.PRESCRIPTION]: "K objednání receptu",
};

test.each(flowData)(
"clicking the options changes the selection (initial %s, clicking %s, expecting %s)",
(initialOrderType: OrderType, selectingOptionByText: string | null, expectingSelectedOrderType: OrderType) => {
render(<OrderTypeForm initialOrderType={initialOrderType} onSubmit={() => null} />);

expect(getSelectedOrderType()).toBe(initialOrderType);

// we also check the default works
if (selectingOptionByText !== null) {
fireEvent.click(screen.getByText(new RegExp(`${selectingOptionByText}`, "i")));
}

expect(getSelectedOrderType()).toBe(expectingSelectedOrderType);
}
);

test.each(flowData)(
"clicking the options changes the CTA button text (initial order type %s, clicking %s, expecting order type %s)",
(initialOrderType: OrderType, selectingOptionByText: string | null, expectingSelectedOrderType: OrderType) => {
render(<OrderTypeForm initialOrderType={initialOrderType} onSubmit={() => null} />);

expect(getCtaButton().textContent).toMatch(buttonLabel[initialOrderType]);

// we also check the default works
if (selectingOptionByText !== null) {
fireEvent.click(screen.getByText(new RegExp(`${selectingOptionByText}`, "i")));
}

expect(getCtaButton().textContent).toMatch(buttonLabel[expectingSelectedOrderType]);
}
);

test.each(flowData)(
"clicking the CTA button submits with correct order type value (initial %s, clicking %s, expecting %s)",
(initialOrderType: OrderType, selectingOptionByText: string | null, expectingSelectedOrderType: OrderType) => {
let selectedOrderType = null;
render(
<OrderTypeForm
initialOrderType={initialOrderType}
onSubmit={(orderType) => {
selectedOrderType = orderType;
}}
/>
);

// we also check the default works
if (selectingOptionByText !== null) {
fireEvent.click(screen.getByText(new RegExp(`${selectingOptionByText}`, "i")));
}

expect(selectedOrderType).toBe(null);
fireEvent.click(getCtaButton());
expect(selectedOrderType).toBe(expectingSelectedOrderType);
}
);
54 changes: 54 additions & 0 deletions react-ts/src/components/OrderTypeForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React, { useState } from "react";
import { OrderType } from "../state";
import { FancyRadio, FancyRadioContainer } from "./design/FancyRadio";

const orderTypeOptions: { [key in OrderType]: string } = {
[OrderType.SCREENING]: "Chci se\nobjednat na vyšetření",
[OrderType.PRESCRIPTION]: "Chci si\nobjednat recept",
};

const submitButtonLabel: { [key in OrderType]: string } = {
[OrderType.SCREENING]: "K objednání vyšetření →",
[OrderType.PRESCRIPTION]: "K objednání receptu →",
};

export function OrderTypeForm({
initialOrderType,
onSubmit,
}: {
initialOrderType: OrderType;
onSubmit: (orderType: OrderType) => void;
}) {
const [orderType, setOrderType] = useState<OrderType>(initialOrderType);

return (
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit(orderType);
}}
>
<FancyRadioContainer>
{Object.entries(orderTypeOptions).map(([optionOrderType, optionLabel]) => {
const labelRows = optionLabel.split("\n");
return (
<FancyRadio
key={optionOrderType}
name="order-type"
value={optionOrderType}
checked={orderType == optionOrderType}
onChange={() => setOrderType(optionOrderType as OrderType)}
>
{labelRows[0]}
<br />
<strong>{labelRows[1]}</strong>
</FancyRadio>
);
})}
</FancyRadioContainer>
<div className="OrderTypeForm-cta">
<button type="submit">{submitButtonLabel[orderType]}</button>
</div>
</form>
);
}
22 changes: 22 additions & 0 deletions react-ts/src/components/design/FancyRadio.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React, { InputHTMLAttributes, ReactNode } from "react";

export function FancyRadioContainer({ children }: { children: React.ReactNode }) {
return <div className="FancyRadio-container">{children}</div>;
}

export function FancyRadio({
children,
name,
value,
...props
}: { children: ReactNode; name: string; value: string } & Partial<InputHTMLAttributes<HTMLInputElement>>) {
const id = `${name}-${value}`;
return (
<div className={`FancyRadio-item ${props.checked ? "FancyRadio-item--selected" : "FancyRadio-item--not-selected"}`}>
<input type="radio" className="FancyRadio-input" name={name} value={value} {...props} id={id} />
<label htmlFor={id} className="FancyRadio-label">
{children}
</label>
</div>
);
}
5 changes: 5 additions & 0 deletions react-ts/src/components/design/PageContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import React from "react";

export function PageContent({ children }: { children: React.ReactNode }) {
return <main className="PageContent">{children}</main>;
}
21 changes: 19 additions & 2 deletions react-ts/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
import React from "react";
import React, { Dispatch, useMemo, useState } from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";

import App from "./App";
import { AppState, initialAppState, StateContext } from "./state";

const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement);

root.render(
<React.StrictMode>
<App />
<BrowserRouter>
<WithState>
<App />
</WithState>
</BrowserRouter>
</React.StrictMode>
);

function WithState({ children }: { children: React.ReactNode }) {
const [appState, setAppState] = useState<AppState>(initialAppState);
const stateContextValue = useMemo<[AppState, Dispatch<AppState>]>(
() => [appState, setAppState],
[appState, setAppState]
);

return <StateContext.Provider value={stateContextValue}>{children}</StateContext.Provider>;
}
25 changes: 25 additions & 0 deletions react-ts/src/routes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from "react";
import { RouteObject } from "react-router-dom";

import { Homepage } from "./routes/Homepage";
import { Login } from "./routes/Login";
import { NoMatch } from "./routes/NoMatch";

type Route = Omit<RouteObject, "path"> & {
path: string;
};

export const HomePageRoute: Route = {
path: "/",
element: <Homepage />,
};
export const LoginRoute: Route = {
path: "/login",
element: <Login />,
};
export const NoMatchRoute: Route = {
path: "*",
element: <NoMatch />,
};

export const routes: Route[] = [HomePageRoute, LoginRoute, NoMatchRoute];
43 changes: 43 additions & 0 deletions react-ts/src/routes/Homepage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";

import { OrderType, useUser } from "../state";
import { PageContent } from "../components/design/PageContent";
import { CatEmoji } from "../components/CatEmoji";
import { LoginRoute } from "../routes";

const orderTypeLabel: { [key in OrderType]: string } = {
[OrderType.SCREENING]: "vyšetření",
[OrderType.PRESCRIPTION]: "recept",
};

export function Homepage() {
const [user] = useUser();
const navigate = useNavigate();

useEffect(() => {
if (user.orderType === null) {
navigate(LoginRoute.path);
}
}, [navigate, user]);

if (user.orderType === null) {
return null;
}

return (
<>
<h2>... už to sviští!</h2>
<PageContent>
<p>
Gratulujeme k výběru! Vaše volba je: <strong>{orderTypeLabel[user.orderType]}</strong>.
</p>
<p>Než Vás naši asistenti obslouží, přijměte jako malý dárek následující kočičí emoji.</p>
<CatEmoji />
<p>
Chcete změnit výběr? <Link to={LoginRoute.path}>Volte znovu.</Link>
</p>
</PageContent>
</>
);
}
16 changes: 16 additions & 0 deletions react-ts/src/routes/Login.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";

import { getHeading, WrappedApp } from "../testUtils";
import { LoginRoute } from "../routes";

test("clicking the CTA button redirects to the homepage", () => {
render(<WrappedApp initialPath={LoginRoute.path} />);

expect(getHeading().textContent).toMatch(/objednávkový systém/i);

const submitButton = screen.getByRole<HTMLButtonElement>("button", { name: /K objednání/i });
fireEvent.click(submitButton);

expect(getHeading().textContent).toMatch("už to sviští");
});
Loading