Skip to content

Commit

Permalink
add Api client and provider
Browse files Browse the repository at this point in the history
  • Loading branch information
lkleuver authored and praseodym committed Apr 29, 2024
1 parent 630182a commit f4232d8
Show file tree
Hide file tree
Showing 22 changed files with 324 additions and 46 deletions.
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
39 changes: 26 additions & 13 deletions frontend/app/component/form/voters_and_votes/VotersAndVotesForm.tsx
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,33 +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 request = {

doSubmit({
data: {
voters_counts: {
poll_card_count: elements.pollCards.value,
proxy_certificate_count: elements.proxyCertificates.value,
voter_card_count: elements.voterCards.value,
total_admitted_voters_count: elements.totalAdmittedVoters.value
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: elements.votesOnCandidates.value,
blank_votes_count: elements.blankVotes.value,
invalid_votes_count: elements.invalidVotes.value,
total_votes_cast_coun: elements.totalVotesCast.value
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)
}
}
};
console.log(request);
});
}

return (
<form onSubmit={handleSubmit}>
<h3>Toegelaten kiezers en uitgebrachte stemmen</h3>
{data && <p>Success</p>}
{error && (
<p>
Error {error.code} {error.message || ""}
</p>
)}
<InputGrid>
<InputGrid.Header>
<th>Veld</th>
Expand Down Expand Up @@ -113,7 +124,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
2 changes: 1 addition & 1 deletion frontend/app/module/input/page/PollingStationPage.test.tsx
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>
);
};
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
42 changes: 30 additions & 12 deletions frontend/lib/api-mocks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
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 @@ -8,18 +13,31 @@ type PingResponseBody = {
pong: string;
};

const pingHandler = http.post<PingParams, PingRequestBody, PingResponseBody>(
"/v1/ping",
async ({ request }) => {
const data = await request.json();
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
});
}
);
const pong = data.ping || "pong";

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

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];
export const handlers: HttpHandler[] = [pingHandler, pollingStationDataEntryHandler];
20 changes: 20 additions & 0 deletions frontend/lib/api/ApiClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export class ApiClient {
host: string;

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

async postRequest(path: string, requestBody: object): Promise<Response> {
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 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";
}
3 changes: 1 addition & 2 deletions frontend/lib/api/gen/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ export interface POLLING_STATION_DATA_ENTRY_REQUEST_PARAMS {
id: number;
entry_number: number;
}
export type POLLING_STATION_DATA_ENTRY_REQUEST_PATH =
`/api/polling_stations/${number}/data_entries/${number};`;
export type POLLING_STATION_DATA_ENTRY_REQUEST_PATH = `/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;
}
62 changes: 62 additions & 0 deletions frontend/lib/api/useApiRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import * as React from "react";

import { useApi } from "./useApi";
import { ApiResponse, ApiResponseSuccess } from "./api";

export type UseApiRequestReturn<REQUEST_BODY, DATA, ERROR> = [
(requestBody: REQUEST_BODY) => void,
{
loading: boolean;
error: ERROR | null;
data: DATA | null;
}
];

export interface UseApiRequestParams<DATA, ERROR> {
path: string;
responseHandler: (response: Response) => Promise<DATA | ERROR>;
}

export function useApiRequest<REQUEST_BODY, DATA extends ApiResponseSuccess, ERROR extends ApiResponse>({
path,
responseHandler
}: UseApiRequestParams<DATA, ERROR>): UseApiRequestReturn<REQUEST_BODY, DATA, ERROR> {
const { client } = useApi();
const [data, setData] = React.useState<DATA | null>(null);
const [error, setError] = React.useState<ERROR | null>(null);
const [apiRequest, setApiRequest] = React.useState<REQUEST_BODY | null>(null);

React.useEffect(() => {
const doRequest = async (b: REQUEST_BODY) => {
const response = await client.postRequest(path, b as object);

const result = await responseHandler(response);
if (result.status === "20x") {
setData(result as DATA);
} else {
setError(result as ERROR);
}
};

if (apiRequest) {
doRequest(apiRequest).catch((e: unknown) => {
console.error(e);
});
}
}, [apiRequest, client, path, responseHandler]);

const makeRequest = React.useCallback((requestBody: REQUEST_BODY) => {
setApiRequest(requestBody);
}, []);

const loading = false;

return [
makeRequest,
{
loading,
error,
data
}
];
}
Loading

0 comments on commit f4232d8

Please sign in to comment.