Skip to content

Commit

Permalink
Landing Page (#62)
Browse files Browse the repository at this point in the history
* move logged in email from landing page to meta tag

* feat: header with logout link

* feat: landing page

* test: mock fetch for all tests

* test: can open modal from landing page

* feat: button hover colors

* standardize colors in tailwind config
  • Loading branch information
skyqrose authored Jul 26, 2024
1 parent df50a71 commit 1486b76
Show file tree
Hide file tree
Showing 14 changed files with 144 additions and 58 deletions.
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export default tseslint.config(
},
},
{
files: ["js/test/", "js/**/*.test.*"],
files: ["js/test/**", "js/**/*.test.*"],
plugins: {
jest: pluginJest,
"jest-dom": pluginJestDom,
Expand Down
8 changes: 8 additions & 0 deletions js/components/app.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { reload } from "../browser";
import { Header } from "./header";
import { Help } from "./help";
import { Home } from "./home";
import { List } from "./operatorSignIn/list";
import { captureException } from "@sentry/react";
import { ReactElement, useEffect } from "react";
import {
createBrowserRouter,
Outlet,
RouterProvider,
useRouteError,
} from "react-router-dom";
Expand Down Expand Up @@ -45,6 +47,12 @@ const ErrorBoundary = (): ReactElement => {
const router = createBrowserRouter([
{
errorElement: <ErrorBoundary />,
element: (
<>
<Header />
<Outlet />
</>
),
children: [
{
path: "/",
Expand Down
10 changes: 10 additions & 0 deletions js/components/header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const Header = () => {
return (
<div className="w-full bg-gray-100 p-2 flex justify-between">
<img src="/images/logo.svg" alt="MBTA" className="w-8" />
<a href="/logout" className="underline p-1">
Log out
</a>
</div>
);
};
80 changes: 64 additions & 16 deletions js/components/home.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,72 @@
import { OperatorSignInModal } from "./operatorSignIn/operatorSignInModal";
import { ReactElement } from "react";
import { DateTime } from "luxon";
import { ReactElement, useMemo, useState } from "react";
import { Link } from "react-router-dom";

export const Home = (): ReactElement => {
const [modalOpen, setModalOpen] = useState(false);
// check once to avoid it changing while looking at the page
// no attempt yet to verify time zones / rolling the date at a time other than midnight
const today = useMemo<string>(() => DateTime.now().toISODate(), []);
const [selectedDate, setSelectedDate] = useState<string>(today);
return (
<>
<div className="text-3xl">
🪐
<span className="text-mbta-orange">O</span>
<span className="text-mbta-red">r</span>
<span className="text-mbta-blue">b</span>it
<div>
<Link to="/list">
<button className="bg-mbta-blue text-gray-100 rounded-md p-2 text-sm m-5">
Sign-in history
</button>
</Link>
</div>
<div className="max-w-lg mx-auto">
<div className="flex justify-between">
<hgroup className="mb-3">
<h1 className="text-xl">Operators</h1>
<p>Search and sign in operators</p>
</hgroup>
<button
className="rounded bg-gray-900 hover:bg-gray-600 text-white p-1 self-center"
onClick={() => {
setModalOpen(true);
}}
>
Sign In Operator
</button>
</div>
<OperatorSignInModal />
</>
<div className="flex justify-between gap-2 items-end mb-3">
<label className="flex flex-col">
<span className="text-sm">Service Date</span>
<input
type="date"
value={selectedDate}
onChange={(evt) => {
setSelectedDate(evt.target.value);
}}
className="rounded"
/>
</label>
<button
className="rounded border border-gray-300 hover:bg-gray-50 px-2 py-1 disabled:text-gray-300"
disabled={selectedDate === today}
onClick={() => {
setSelectedDate(today);
}}
>
Today
</button>
<button
className="rounded border border-gray-300 hover:bg-gray-50 px-2 py-1"
onClick={() => {
console.log("TODO");
}}
>
Export Records
</button>
</div>
<p className="mb-3">(Search will go here)</p>
<Link className="block" to="/list">
<button className="bg-mbta-blue text-gray-100 rounded-md p-2 text-sm">
Sign-in history
</button>
</Link>
<OperatorSignInModal
show={modalOpen}
close={() => {
setModalOpen(false);
}}
/>
</div>
);
};
15 changes: 10 additions & 5 deletions js/components/operatorSignIn/operatorSignInModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,13 @@ const submit = (
});
};

export const OperatorSignInModal = (): ReactElement => {
const [show, setShow] = useState<boolean>(true);
export const OperatorSignInModal = ({
show,
close,
}: {
show: boolean;
close: () => void;
}): ReactElement => {
const [badge, setBadge] = useState<BadgeEntry | null>(null);
const [complete, setComplete] = useState<CompleteState | null>(null);

Expand All @@ -66,13 +71,13 @@ export const OperatorSignInModal = (): ReactElement => {
useEffect(() => {
if (complete === CompleteState.SUCCESS) {
const timeout = setTimeout(() => {
setShow(false);
close();
}, 1000);
return () => {
clearTimeout(timeout);
};
}
}, [complete]);
}, [complete, close]);

const employee =
employees.status === "ok" &&
Expand All @@ -85,7 +90,7 @@ export const OperatorSignInModal = (): ReactElement => {
show={show}
title={<span className="text-lg font-bold">Fit for Duty Check</span>}
onClose={() => {
setShow(false);
close();
}}
>
{complete === CompleteState.SIGN_IN_ERROR && badge !== null ?
Expand Down
14 changes: 7 additions & 7 deletions js/test/components/app.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { fetch } from "../../browser";
import { App } from "../../components/app";
import { render } from "@testing-library/react";

describe("App", () => {
test("error boundary renders on error", () => {
jest.mock("../../components/operatorSignIn/operatorSignInModal", () => ({
OperatorSignInModal: () => {
throw new Error(
"Ah, Houston, we've had a problem. We've had a Main B Bus Undervolt.",
);
},
}));
// Cause an error to be thrown anywhere in the app
(fetch as jest.MockedFn<typeof fetch>).mockImplementation(() => {
throw new Error(
"Ah, Houston, we've had a problem. We've had a Main B Bus Undervolt.",
);
});

jest.spyOn(console, "error").mockImplementation(() => {});
const view = render(<App />);
Expand Down
13 changes: 7 additions & 6 deletions js/test/components/home.test.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { Home } from "../../components/home";
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";

jest.mock("../../components/operatorSignIn/operatorSignInModal", () => ({
OperatorSignInModal: () => <div>Mock modal</div>,
}));

describe("home", () => {
test("loads orbit placeholder", () => {
test("can open modal", async () => {
const view = render(
<MemoryRouter>
<Home />
</MemoryRouter>,
);
expect(view.getByText(/🪐/)).toBeInTheDocument();
expect(view.queryByText(/fit for duty/i)).not.toBeInTheDocument();
await userEvent.click(
view.getByRole("button", { name: "Sign In Operator" }),
);
expect(view.getByText(/fit for duty/i)).toBeInTheDocument();
});
});
27 changes: 14 additions & 13 deletions js/test/components/operatorSignIn/operatorSignInModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,29 +29,28 @@ jest.mock("../../../hooks/useNfc", () => ({
}),
}));

jest.mock("../../../browser", () => ({
fetch: jest.fn(),
}));

describe("OperatorSignInModal", () => {
test("shows badge tap message by default", () => {
const view = render(<OperatorSignInModal />);
const view = render(<OperatorSignInModal show={true} close={jest.fn()} />);

expect(view.getByText(/waiting for badge tap/i)).toBeInTheDocument();
});

test("shows badge tap unsupported message when NFC isn't available", () => {
jest.mocked(nfcSupported).mockReturnValueOnce(false);

const view = render(<OperatorSignInModal />);
const view = render(<OperatorSignInModal show={true} close={jest.fn()} />);

expect(view.getByText(/badge tap is not supported/i)).toBeInTheDocument();
});

test("can close the modal", async () => {
const view = render(<OperatorSignInModal />);
const close = jest.fn();
const view = render(<OperatorSignInModal show={true} close={close} />);

await userEvent.click(view.getByRole("button", { name: "[x]" }));
expect(close).toHaveBeenCalled();
view.rerender(<OperatorSignInModal show={false} close={close} />);

expect(view.queryByText(/fit for duty check/i)).not.toBeInTheDocument();
});
Expand All @@ -62,7 +61,7 @@ describe("OperatorSignInModal", () => {
ok: true,
} as Response);

const view = render(<OperatorSignInModal />);
const view = render(<OperatorSignInModal show={true} close={jest.fn()} />);
await userEvent.type(view.getByRole("textbox"), "123");
await userEvent.click(view.getByRole("button", { name: "OK" }));

Expand All @@ -83,7 +82,7 @@ describe("OperatorSignInModal", () => {
statusText: "Internal Server Error",
});

const view = render(<OperatorSignInModal />);
const view = render(<OperatorSignInModal show={true} close={jest.fn()} />);
await userEvent.type(view.getByRole("textbox"), "123");
await userEvent.click(view.getByRole("button", { name: "OK" }));

Expand All @@ -97,30 +96,32 @@ describe("OperatorSignInModal", () => {
});

test("shows failure component when no operator is found after an NFC tap", () => {
const { rerender, ...view } = render(<OperatorSignInModal />);
const close = jest.fn();
const view = render(<OperatorSignInModal show={true} close={jest.fn()} />);

jest.mocked(useNfc).mockReturnValueOnce({
result: { status: "success", data: "bad_serial" },
abortController: new AbortController(),
});
jest.mocked(findEmployeeByBadgeSerial).mockReturnValueOnce(undefined);

rerender(<OperatorSignInModal />);
view.rerender(<OperatorSignInModal show={true} close={close} />);

expect(
view.getByText(/something went wrong when looking up the owner/i),
).toBeInTheDocument();
});

test("shows failure component when NFC tap fails", () => {
const view = render(<OperatorSignInModal />);
const close = jest.fn();
const view = render(<OperatorSignInModal show={true} close={close} />);

jest.mocked(useNfc).mockReturnValueOnce({
result: { status: "error", error: "error" },
abortController: new AbortController(),
});

view.rerender(<OperatorSignInModal />);
view.rerender(<OperatorSignInModal show={true} close={close} />);

expect(
view.getByText(/something went wrong when looking for a badge tap/i),
Expand Down
4 changes: 4 additions & 0 deletions js/test/helpers/promiseWithResolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ export const PromiseWithResolvers = <T>(): {

return { promise, resolve, reject };
};

export const neverPromise = <T>(): Promise<T> => {
return new Promise((_resolve, _reject) => {});
};
6 changes: 6 additions & 0 deletions js/test/setupTest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import "@testing-library/jest-dom";
import { neverPromise } from "./helpers/promiseWithResolvers";
// add all jest-extended matchers
import * as jestExtendedMatchers from "jest-extended";

expect.extend(jestExtendedMatchers);

jest.mock("../browser", () => ({
fetch: jest.fn(() => neverPromise()),
reload: jest.fn(),
}));
3 changes: 3 additions & 0 deletions lib/orbit_web/components/layouts/root.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
<meta name="csrf-token" content={get_csrf_token()} />

<% # Orbit metadata %>
<%= if @conn.assigns[:logged_in_user] do %>
<meta name="email" content={@logged_in_user.email} />
<% end %>
<meta name="release" content={@release} />

<%= if @conn.assigns[:sentry_dsn] do %>
Expand Down
2 changes: 0 additions & 2 deletions lib/orbit_web/controllers/react_app/react_app.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,5 @@
You must enable JavaScript to view this page.
</noscript>
<div id="app"></div>
Logged in as <%= assigns.logged_in_user.email %>
<div><a href={~p"/logout"}>Logout</a></div>
<script src={~p"/assets/app.js"}>
</script>
7 changes: 1 addition & 6 deletions priv/static/images/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 9 additions & 2 deletions tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,19 @@ export default {
"mbta-red": "#DA291C",
"mbta-orange": "#ED8B00",
"mbta-blue": "#003DA5",
black: "#000000",
success: "#145A06",
warning: "#FFC961",
error: "#B3000F",
white: "#ffffff",
gray: {
100: "#EAEAEA",
50: "#F6F6F6",
100: "#E8E8E8",
200: "#D9D9D9",
300: "#929292",
600: "#494F5C",
900: "#1C1E23",
},
black: "#000000",
green: {
500: "#2EC92E",
},
Expand Down

0 comments on commit 1486b76

Please sign in to comment.