Skip to content

Commit

Permalink
Merge branch 'main' into backend-framework
Browse files Browse the repository at this point in the history
  • Loading branch information
praseodym authored Mar 19, 2024
2 parents 2431c27 + 8b1e823 commit 7e56c2d
Show file tree
Hide file tree
Showing 37 changed files with 13,515 additions and 0 deletions.
35 changes: 35 additions & 0 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Kiesraad-frontend

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]

env:
API_MODE: msw

jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- name: Use node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install
run: npm install
- name: Lint
run: npm run lint
- name: Test
run: npm test
- name: "e2e test install"
run: npx playwright install --with-deps
- name: "e2e test"
run: npm run e2e
- name: "Build"
run: npm run build
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.vscode
6 changes: 6 additions & 0 deletions frontend/.babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"presets": [
"@babel/preset-env",
["@babel/preset-react", { "runtime": "automatic" }]
]
}
21 changes: 21 additions & 0 deletions frontend/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/strict-type-checked",
"plugin:react-hooks/recommended",
"plugin:jsx-a11y/recommended",
"plugin:playwright/recommended",
"prettier"
],
ignorePatterns: ["dist", ".eslintrc.cjs"],
parser: "@typescript-eslint/parser",
plugins: ["react-refresh", "jsx-a11y", "prettier", "@typescript-eslint"],
rules: {
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }]
},
parserOptions: {
project: "./tsconfig.json"
}
};
25 changes: 25 additions & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
test-results
3 changes: 3 additions & 0 deletions frontend/.ladle/config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default {
stories: "lib/ui/**/*.stories.tsx"
};
1 change: 1 addition & 0 deletions frontend/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v20.11.1
2 changes: 2 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Electoral Council election results management system - Frontend

4 changes: 4 additions & 0 deletions frontend/app/app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { expect, test } from "vitest";
test("it is an app", () => {
expect(true).toBe(true);
});
9 changes: 9 additions & 0 deletions frontend/app/layout/RootLayout/RootLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Outlet } from "react-router-dom";

export function RootLayout() {
return (
<div>
<Outlet />
</div>
);
}
1 change: 1 addition & 0 deletions frontend/app/layout/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./RootLayout/RootLayout";
32 changes: 32 additions & 0 deletions frontend/app/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import ReactDOM from "react-dom/client";
import { StrictMode } from "react";
import { createBrowserRouter, RouterProvider } from "react-router-dom";

// ignore in prod
import { startMockAPI } from "./msw-mock-api.ts";
import { routes } from "./routes.tsx";

const rootDiv = document.getElementById("root");
if (!rootDiv) throw new Error("Root div not found");

const root = ReactDOM.createRoot(rootDiv);

function render() {
const router = createBrowserRouter(routes, {
future: {
v7_normalizeFormMethod: true
}
});

root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
);
}

if (process.env.MSW) {
startMockAPI().then(render).catch(console.error);
} else {
render();
}
10 changes: 10 additions & 0 deletions frontend/app/module/global/page/HomePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import {Button} from "@kiesraad/ui";

export function HomePage() {
return (
<div>
<h1>Home</h1>
<Button>Click</Button>
</div>
)
}
43 changes: 43 additions & 0 deletions frontend/app/msw-mock-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const sleep = async (ms: number) => new Promise((res) => setTimeout(res, ms));
const randInt = (min: number, max: number) => min + Math.floor(Math.random() * (max - min));

export async function startMockAPI() {
// dynamic imports to make extremely sure none of this code ends up in the prod bundle
const { handlers } = await import("@kiesraad/api-mocks");
const { http } = await import("msw");
const { setupWorker } = await import("msw/browser");

// defined in here because it depends on the dynamic import
const interceptAll = http.all("/v1/*", async () => {
// random delay on all requests to simulate a real API
await sleep(randInt(200, 400));
// don't return anything means fall through to the real handlers
});

// https://mswjs.io/docs/api/setup-worker/start#options
await setupWorker(interceptAll, ...handlers).start({
quiet: true, // don't log successfully handled requests
// custom handler only to make logging less noisy. unhandled requests still
// pass through to the server
onUnhandledRequest(req) {
const path = new URL(req.url).pathname;
// Files that get pulled in dynamic imports. It is expected that MSW will
// not handle them and they fall through to the dev server, so warning
// about them is just noise.
const ignore = [
path.startsWith("/app"),
path.startsWith("/lib"),
path.startsWith("/node_modules"),
path.startsWith("/js")
].some(Boolean);
if (!ignore) {
// message format copied from MSW source
console.warn(`[MSW] Warning: captured an API request without a matching request handler:
${req.method} ${path}
If you want to intercept this unhandled request, create a request handler for it.`);
}
}
});
}
11 changes: 11 additions & 0 deletions frontend/app/routes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createRoutesFromElements, Route } from "react-router-dom";
import { RootLayout } from "./layout";
import { HomePage } from "./module/global/page/HomePage";


export const routes = createRoutesFromElements(
<Route element={<RootLayout />}>
<Route path="*" element={<div>Not found</div>} />
<Route index path="/" element={<HomePage />} />
</Route>
);
6 changes: 6 additions & 0 deletions frontend/app/test/e2e/smoketest.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { test, expect } from "@playwright/test";

test("smoke test", async ({ page }) => {
await page.goto("/");
expect(true).toBe(true);
});
37 changes: 37 additions & 0 deletions frontend/app/test/unit/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";

import { handlers } from "@kiesraad/api-mocks";

export const server = setupServer(
...handlers.map((h) => {
// Node's Fetch implementation does not accept URLs with a protocol and host
h.info.path = "http://testhost" + h.info.path;
return h;
})
);

// Override request handlers in order to test special cases
export function overrideOnce(
method: keyof typeof http,
path: string,
status: number,
body: string | Record<string, unknown>
) {
server.use(
http[method](
path,
() =>
// https://mswjs.io/docs/api/response/once
typeof body === "string" ? new HttpResponse(body, { status }) : HttpResponse.json(body, { status }),
{ once: true }
)
);
}
15 changes: 15 additions & 0 deletions frontend/app/test/unit/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import "@testing-library/jest-dom/vitest";

import { cleanup } from "@testing-library/react";
import { afterAll, afterEach, beforeAll } from "vitest";
import { server } from "./server";

beforeAll(() => {
server.listen();
});
afterEach(() => {
cleanup();
});
afterAll(() => {
server.restoreHandlers();
});
13 changes: 13 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>TBD</title>

<meta viewport="width=device-width, initial-scale=1" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./app/main.tsx"></script>
</body>
</html>
25 changes: 25 additions & 0 deletions frontend/lib/api-mocks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { http, type HttpHandler, HttpResponse } from "msw";

type PingParams = Record<string, never>;
type PingRequestBody = {
ping: string;
};
type PingResponseBody = {
pong: string;
};

const pingHandler = http.post<PingParams, PingRequestBody, PingResponseBody>(
"/v1/ping",
async ({ request }) => {
const data = await request.json();

const pong = data.ping || "pong";

return HttpResponse.json({
pong
});
}
);


export const handlers: HttpHandler[] = [pingHandler];
20 changes: 20 additions & 0 deletions frontend/lib/api/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, test } from "vitest";

type Response = {
pong: string;
};

describe("Mock api works", () => {
test("echos a value", async () => {
const resp = await fetch("http://testhost/v1/ping", {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify({ ping: "test" })
});
const result = (await resp.json()) as unknown as Response;

expect(result.pong).toBe("test");
});
});
27 changes: 27 additions & 0 deletions frontend/lib/ui/Button/Button.module.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
.button-default {
color: #fff;
border-radius: 8px;
border: 1px solid #202939;
background: #202939;
/* Shadow/xs */
box-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);

display: flex;
width: 208px;
padding: 10px 16px;
justify-content: center;
align-items: center;
gap: 12px;


&:focus {
outline: none;
box-shadow: 0px 0px 4px 4px rgba(66, 210, 243, 0.4);
}

&:disabled {
background: #f2f2f2;
border: 1px solid #f2f2f2;
color: #b3b3b3;
}
}
5 changes: 5 additions & 0 deletions frontend/lib/ui/Button/Button.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { Story } from "@ladle/react";

import { Button } from "./Button";

export const DefaultButton: Story = () => <Button>Click me</Button>;
8 changes: 8 additions & 0 deletions frontend/lib/ui/Button/Button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { render } from "@testing-library/react";
import { Button } from "./Button";
import { expect, test } from "vitest";

test("The button works", () => {
render(<Button>Click me</Button>);
expect(true).toBe(true);
});
Loading

0 comments on commit 7e56c2d

Please sign in to comment.