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

Submit data entry from frontend to backend API #31

Merged
merged 9 commits into from
May 17, 2024
Merged
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { render, screen } from "app/test/unit/test-utils";
import { userEvent } from "@testing-library/user-event";
import { describe, expect, test } from "vitest";

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { usePollingStationDataEntry } from "@kiesraad/api";
import { Button, InputGrid } from "@kiesraad/ui";
import { usePositiveNumberInputMask } from "@kiesraad/util";

Expand All @@ -17,27 +18,43 @@ interface VotersAndVotesFormElement extends HTMLFormElement {
}

export function VotersAndVotesForm() {
const { register, format } = usePositiveNumberInputMask();
const { register, format, deformat } = usePositiveNumberInputMask();
const [doSubmit, { data, loading, error }] = usePollingStationDataEntry({
id: 1,
entry_number: 1,
});

function handleSubmit(event: React.FormEvent<VotersAndVotesFormElement>) {
event.preventDefault();
const elements = event.currentTarget.elements;
const result = {
pollCards: elements.pollCards.value,
proxyCertificates: elements.proxyCertificates.value,
voterCards: elements.voterCards.value,
totalAdmittedVoters: elements.totalAdmittedVoters.value,
votesOnCandidates: elements.votesOnCandidates.value,
blankVotes: elements.blankVotes.value,
invalidVotes: elements.invalidVotes.value,
totalVotesCast: elements.totalVotesCast.value,
};
console.log(result);

doSubmit({
data: {
voters_counts: {
poll_card_count: deformat(elements.pollCards.value),
proxy_certificate_count: deformat(elements.proxyCertificates.value),
voter_card_count: deformat(elements.voterCards.value),
total_admitted_voters_count: deformat(elements.totalAdmittedVoters.value),
},
votes_counts: {
votes_candidates_counts: deformat(elements.votesOnCandidates.value),
blank_votes_count: deformat(elements.blankVotes.value),
invalid_votes_count: deformat(elements.invalidVotes.value),
total_votes_cast_count: deformat(elements.totalVotesCast.value),
},
},
});
}

return (
<form onSubmit={handleSubmit}>
<h3>Toegelaten kiezers en uitgebrachte stemmen</h3>
{data && <p>Success</p>}
{error && (
<p>
Error {error.errorCode} {error.message || ""}
</p>
)}
<InputGrid>
<InputGrid.Header>
<th>Veld</th>
Expand Down Expand Up @@ -127,7 +144,9 @@ export function VotersAndVotesForm() {
</InputGrid.Body>
</InputGrid>
<br /> <br />
<Button type="submit">Volgende</Button>
<Button type="submit" disabled={loading}>
Volgende
</Button>
</form>
);
}
Expand Down
5 changes: 4 additions & 1 deletion frontend/app/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom";
// ignore in prod
import { startMockAPI } from "./msw-mock-api.ts";
import { routes } from "./routes.tsx";
import { ApiProvider } from "@kiesraad/api";

const rootDiv = document.getElementById("root");
if (!rootDiv) throw new Error("Root div not found");
Expand All @@ -20,7 +21,9 @@ function render() {

root.render(
<StrictMode>
<RouterProvider router={router} />
<ApiProvider host={process.env.API_HOST || ""}>
<RouterProvider router={router} />
</ApiProvider>
</StrictMode>,
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render } from "@testing-library/react";
import { render } from "app/test/unit/test-utils";
import { describe, expect, test } from "vitest";
import { PollingStationPage } from "./PollingStationPage";

Expand Down
12 changes: 1 addition & 11 deletions frontend/app/msw-mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,19 @@ 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
quiet: true,
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"),
Expand All @@ -32,7 +23,6 @@ export async function startMockAPI() {
path.startsWith("/font"),
].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}
Expand Down
11 changes: 11 additions & 0 deletions frontend/app/test/unit/Providers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as React from "react";
import { StrictMode } from "react";
import { ApiProvider } from "@kiesraad/api";

export const Providers = ({ children }: { children: React.ReactNode }) => {
return (
<StrictMode>
<ApiProvider host="http://testhost">{children}</ApiProvider>
</StrictMode>
);
};
3 changes: 3 additions & 0 deletions frontend/app/test/unit/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./server";
export * from "./test-utils";
export * from "./Providers";
10 changes: 10 additions & 0 deletions frontend/app/test/unit/test-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ReactElement } from "react";
import { Providers } from "./Providers";

import { render, RenderOptions } from "@testing-library/react";

const customRender = (ui: ReactElement, options?: Omit<RenderOptions, "wrapper">) =>
render(ui, { wrapper: Providers, ...options });

export * from "@testing-library/react";
export { customRender as render };
2 changes: 2 additions & 0 deletions frontend/app/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
API_MODE: "mock" | "local";
API_HOST: string;
VERSION: string;
}
}
Expand Down
27 changes: 26 additions & 1 deletion frontend/lib/api-mocks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { http, type HttpHandler, HttpResponse } from "msw";
import {
POLLING_STATION_DATA_ENTRY_REQUEST_BODY,
POLLING_STATION_DATA_ENTRY_REQUEST_PARAMS,
} from "@kiesraad/api";

type ParamsToString<T> = {
[P in keyof T]: string;
};

type PingParams = Record<string, never>;
type PingRequestBody = {
Expand All @@ -21,4 +29,21 @@ const pingHandler = http.post<PingParams, PingRequestBody, PingResponseBody>(
},
);

export const handlers: HttpHandler[] = [pingHandler];
const pollingStationDataEntryHandler = http.post<
ParamsToString<POLLING_STATION_DATA_ENTRY_REQUEST_PARAMS>,
POLLING_STATION_DATA_ENTRY_REQUEST_BODY
>("/v1/api/polling_stations/:id/data_entries/:entry_number", async ({ request }) => {
let json;
try {
json = await request.json();
} catch (e) {
//eslint-disable-next-line
return HttpResponse.text(`${e}`, { status: 422 });
}
if ("voters_counts" in json.data) {
return HttpResponse.text("", { status: 200 });
}
return HttpResponse.json({ message: "missing fields" }, { status: 500 });
});

export const handlers: HttpHandler[] = [pingHandler, pollingStationDataEntryHandler];
87 changes: 87 additions & 0 deletions frontend/lib/api/ApiClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
export interface ApiResponse<DATA = object> {
status: string;
code: number;
data?: DATA;
}

export interface ApiResponseSuccess<DATA = object> extends ApiResponse<DATA> {
status: "success";
}

export interface ApiResponseClientError<DATA = object> extends ApiResponse<DATA> {
status: "client_error";
}

export interface ApiResponseServerError<DATA = object> extends ApiResponse<DATA> {
status: "server_error";
}

export interface ApiResponseErrorData {
errorCode: string;
message: string;
}

export type ApiServerResponse<DATA> =
| (Omit<Response, "json"> & {
status: 200;
json: () => DATA | PromiseLike<DATA>;
})
| (Omit<Response, "json"> & {
status: 422;
json: () => ApiResponseErrorData | PromiseLike<ApiResponseErrorData>;
})
| (Omit<Response, "json"> & {
status: 500;
json: () => ApiResponseErrorData | PromiseLike<ApiResponseErrorData>;
})
| (Omit<Response, "json"> & {
status: number;
json: () => never;
});

export class ApiClient {
host: string;

constructor(host: string) {
this.host = host;
}

async responseHandler<DATA>(response: Response) {
const res = response as ApiServerResponse<DATA>;
if (res.status === 200) {
return { status: "success", code: 200, data: { ok: true } } as ApiResponseSuccess<DATA>;
} else if (res.status === 422) {
const data = await res.json();
return {
status: "client_error",
code: 422,
data,
} as ApiResponseClientError<ApiResponseErrorData>;
} else if (res.status === 500) {
const data = await res.json();
return {
status: "server_error",
code: 500,
data,
} as ApiResponseServerError<ApiResponseErrorData>;
}
throw new Error(`Unexpected response status: ${res.status}`);
}

async postRequest<DATA>(
path: string,
requestBody: object,
): Promise<ApiResponseSuccess<DATA> | ApiResponse<ApiResponseErrorData>> {
const host = process.env.NODE_ENV === "test" ? "http://testhost" : "";

const response = await fetch(host + "/v1" + path, {
method: "POST",
body: JSON.stringify(requestBody),
headers: {
"Content-Type": "application/json",
},
});

return this.responseHandler<DATA>(response);
}
}
18 changes: 18 additions & 0 deletions frontend/lib/api/ApiProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as React from "react";
import { ApiClient } from "./ApiClient";

export interface iApiProviderContext {
client: ApiClient;
}

export const ApiProviderContext = React.createContext<iApiProviderContext | null>(null);

export interface ApiProviderProps {
host: string;
children: React.ReactNode;
}
export function ApiProvider({ children, host }: ApiProviderProps) {
const client = React.useMemo(() => new ApiClient(host), [host]);

return <ApiProviderContext.Provider value={{ client }}>{children}</ApiProviderContext.Provider>;
}
18 changes: 18 additions & 0 deletions frontend/lib/api/api.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export interface ApiResponse<DATA = object> {
status: string;
code: number;
message?: string;
data?: DATA;
}

export interface ApiResponseSuccess<DATA = object> extends ApiResponse<DATA> {
status: "20x";
}

export interface ApiResponseClientError<DATA = object> extends ApiResponse<DATA> {
status: "40x";
}

export interface ApiResponseServerError<DATA = object> extends ApiResponse<DATA> {
status: "50x";
}
2 changes: 1 addition & 1 deletion frontend/lib/api/gen/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface POLLING_STATION_DATA_ENTRY_REQUEST_PARAMS {
entry_number: number;
}
export type POLLING_STATION_DATA_ENTRY_REQUEST_PATH =
`/api/polling_stations/${number}/data_entries/${number};`;
`/api/polling_stations/${number}/data_entries/${number}`;
export type POLLING_STATION_DATA_ENTRY_REQUEST_BODY = DataEntryRequest;

/** TYPES **/
Expand Down
5 changes: 5 additions & 0 deletions frontend/lib/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from "./ApiProvider";
export * from "./ApiClient";
export * from "./useApiRequest";
export * from "./usePollingStationDataEntry";
export * from "./gen/openapi";
11 changes: 11 additions & 0 deletions frontend/lib/api/useApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as React from "react";

import { ApiProviderContext, iApiProviderContext } from "./ApiProvider";

export function useApi() {
const context = React.useContext<iApiProviderContext | null>(ApiProviderContext);
if (context === null) {
throw new Error("useApi must be used within an ApiProvider");
}
return context;
}
Loading