diff --git a/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.test.tsx b/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.test.tsx index c45d042fb..825abecd0 100644 --- a/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.test.tsx +++ b/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.test.tsx @@ -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"; diff --git a/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.tsx b/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.tsx index d6fcc1b64..25da60fbc 100644 --- a/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.tsx +++ b/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.tsx @@ -1,3 +1,4 @@ +import { usePollingStationDataEntry } from "@kiesraad/api"; import { Button, InputGrid } from "@kiesraad/ui"; import { usePositiveNumberInputMask } from "@kiesraad/util"; @@ -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) { 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 (

Toegelaten kiezers en uitgebrachte stemmen

+ {data &&

Success

} + {error && ( +

+ Error {error.errorCode} {error.message || ""} +

+ )} Veld @@ -127,7 +144,9 @@ export function VotersAndVotesForm() {

- +
); } diff --git a/frontend/app/main.tsx b/frontend/app/main.tsx index c2f2f4fe6..35bdcf3ad 100644 --- a/frontend/app/main.tsx +++ b/frontend/app/main.tsx @@ -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"); @@ -20,7 +21,9 @@ function render() { root.render( - + + + , ); } diff --git a/frontend/app/module/input/page/PollingStationPage.test.tsx b/frontend/app/module/input/page/PollingStationPage.test.tsx index 81480cfaa..862f7a988 100644 --- a/frontend/app/module/input/page/PollingStationPage.test.tsx +++ b/frontend/app/module/input/page/PollingStationPage.test.tsx @@ -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"; diff --git a/frontend/app/msw-mock-api.ts b/frontend/app/msw-mock-api.ts index 23155e54b..6c871c02a 100644 --- a/frontend/app/msw-mock-api.ts +++ b/frontend/app/msw-mock-api.ts @@ -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"), @@ -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} diff --git a/frontend/app/test/unit/Providers.tsx b/frontend/app/test/unit/Providers.tsx new file mode 100644 index 000000000..e9acee71d --- /dev/null +++ b/frontend/app/test/unit/Providers.tsx @@ -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 ( + + {children} + + ); +}; diff --git a/frontend/app/test/unit/index.ts b/frontend/app/test/unit/index.ts new file mode 100644 index 000000000..dc518755c --- /dev/null +++ b/frontend/app/test/unit/index.ts @@ -0,0 +1,3 @@ +export * from "./server"; +export * from "./test-utils"; +export * from "./Providers"; diff --git a/frontend/app/test/unit/test-utils.ts b/frontend/app/test/unit/test-utils.ts new file mode 100644 index 000000000..89470cd4d --- /dev/null +++ b/frontend/app/test/unit/test-utils.ts @@ -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) => + render(ui, { wrapper: Providers, ...options }); + +export * from "@testing-library/react"; +export { customRender as render }; diff --git a/frontend/app/types.d.ts b/frontend/app/types.d.ts index 21ca63558..df981d58e 100644 --- a/frontend/app/types.d.ts +++ b/frontend/app/types.d.ts @@ -1,6 +1,8 @@ declare global { namespace NodeJS { interface ProcessEnv { + API_MODE: "mock" | "local"; + API_HOST: string; VERSION: string; } } diff --git a/frontend/lib/api-mocks/index.ts b/frontend/lib/api-mocks/index.ts index c4336dbb2..3c353078f 100644 --- a/frontend/lib/api-mocks/index.ts +++ b/frontend/lib/api-mocks/index.ts @@ -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 = { + [P in keyof T]: string; +}; type PingParams = Record; type PingRequestBody = { @@ -21,4 +29,21 @@ const pingHandler = http.post( }, ); -export const handlers: HttpHandler[] = [pingHandler]; +const pollingStationDataEntryHandler = http.post< + ParamsToString, + 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]; diff --git a/frontend/lib/api/ApiClient.ts b/frontend/lib/api/ApiClient.ts new file mode 100644 index 000000000..7a1db34a4 --- /dev/null +++ b/frontend/lib/api/ApiClient.ts @@ -0,0 +1,87 @@ +export interface ApiResponse { + status: string; + code: number; + data?: DATA; +} + +export interface ApiResponseSuccess extends ApiResponse { + status: "success"; +} + +export interface ApiResponseClientError extends ApiResponse { + status: "client_error"; +} + +export interface ApiResponseServerError extends ApiResponse { + status: "server_error"; +} + +export interface ApiResponseErrorData { + errorCode: string; + message: string; +} + +export type ApiServerResponse = + | (Omit & { + status: 200; + json: () => DATA | PromiseLike; + }) + | (Omit & { + status: 422; + json: () => ApiResponseErrorData | PromiseLike; + }) + | (Omit & { + status: 500; + json: () => ApiResponseErrorData | PromiseLike; + }) + | (Omit & { + status: number; + json: () => never; + }); + +export class ApiClient { + host: string; + + constructor(host: string) { + this.host = host; + } + + async responseHandler(response: Response) { + const res = response as ApiServerResponse; + if (res.status === 200) { + return { status: "success", code: 200, data: { ok: true } } as ApiResponseSuccess; + } else if (res.status === 422) { + const data = await res.json(); + return { + status: "client_error", + code: 422, + data, + } as ApiResponseClientError; + } else if (res.status === 500) { + const data = await res.json(); + return { + status: "server_error", + code: 500, + data, + } as ApiResponseServerError; + } + throw new Error(`Unexpected response status: ${res.status}`); + } + + async postRequest( + path: string, + requestBody: object, + ): Promise | ApiResponse> { + 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(response); + } +} diff --git a/frontend/lib/api/ApiProvider.tsx b/frontend/lib/api/ApiProvider.tsx new file mode 100644 index 000000000..245a26371 --- /dev/null +++ b/frontend/lib/api/ApiProvider.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; +import { ApiClient } from "./ApiClient"; + +export interface iApiProviderContext { + client: ApiClient; +} + +export const ApiProviderContext = React.createContext(null); + +export interface ApiProviderProps { + host: string; + children: React.ReactNode; +} +export function ApiProvider({ children, host }: ApiProviderProps) { + const client = React.useMemo(() => new ApiClient(host), [host]); + + return {children}; +} diff --git a/frontend/lib/api/api.d.ts b/frontend/lib/api/api.d.ts new file mode 100644 index 000000000..348164f2b --- /dev/null +++ b/frontend/lib/api/api.d.ts @@ -0,0 +1,18 @@ +export interface ApiResponse { + status: string; + code: number; + message?: string; + data?: DATA; +} + +export interface ApiResponseSuccess extends ApiResponse { + status: "20x"; +} + +export interface ApiResponseClientError extends ApiResponse { + status: "40x"; +} + +export interface ApiResponseServerError extends ApiResponse { + status: "50x"; +} diff --git a/frontend/lib/api/gen/openapi.ts b/frontend/lib/api/gen/openapi.ts index 348246c80..c3991160a 100644 --- a/frontend/lib/api/gen/openapi.ts +++ b/frontend/lib/api/gen/openapi.ts @@ -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 **/ diff --git a/frontend/lib/api/index.ts b/frontend/lib/api/index.ts new file mode 100644 index 000000000..28cd8582d --- /dev/null +++ b/frontend/lib/api/index.ts @@ -0,0 +1,5 @@ +export * from "./ApiProvider"; +export * from "./ApiClient"; +export * from "./useApiRequest"; +export * from "./usePollingStationDataEntry"; +export * from "./gen/openapi"; diff --git a/frontend/lib/api/useApi.ts b/frontend/lib/api/useApi.ts new file mode 100644 index 000000000..fc4f2f21a --- /dev/null +++ b/frontend/lib/api/useApi.ts @@ -0,0 +1,11 @@ +import * as React from "react"; + +import { ApiProviderContext, iApiProviderContext } from "./ApiProvider"; + +export function useApi() { + const context = React.useContext(ApiProviderContext); + if (context === null) { + throw new Error("useApi must be used within an ApiProvider"); + } + return context; +} diff --git a/frontend/lib/api/useApiRequest.ts b/frontend/lib/api/useApiRequest.ts new file mode 100644 index 000000000..a9261960a --- /dev/null +++ b/frontend/lib/api/useApiRequest.ts @@ -0,0 +1,68 @@ +import * as React from "react"; + +import { useApi } from "./useApi"; +import { ApiResponseErrorData } from "./ApiClient"; + +export type UseApiRequestReturn = [ + (requestBody: REQUEST_BODY) => void, + { + loading: boolean; + error: ApiResponseErrorData | null; + data: DATA | null; + }, +]; + +export interface UseApiRequestParams { + path: string; +} + +export function useApiRequest({ + path, +}: UseApiRequestParams): UseApiRequestReturn { + const { client } = useApi(); + const [data, setData] = React.useState(null); + const [error, setError] = React.useState(null); + const [apiRequest, setApiRequest] = React.useState(null); + + React.useEffect(() => { + let isSubscribed = true; + const doRequest = async (b: REQUEST_BODY) => { + const response = await client.postRequest(path, b as object); + if (isSubscribed) { + if (response.status === "success") { + setData(response.data as DATA); + } else { + setError(response.data as ApiResponseErrorData); + } + } + }; + + if (apiRequest) { + doRequest(apiRequest).catch((e: unknown) => { + console.error(e); + }); + } + + return () => { + isSubscribed = false; + }; + }, [apiRequest, client, path]); + + const makeRequest = React.useCallback((requestBody: REQUEST_BODY) => { + setApiRequest(requestBody); + }, []); + + const loading = React.useMemo( + () => apiRequest !== null && data === null && error === null, + [apiRequest, data, error], + ); + + return [ + makeRequest, + { + loading, + error, + data, + }, + ]; +} diff --git a/frontend/lib/api/usePollingStationDataEntry.test.ts b/frontend/lib/api/usePollingStationDataEntry.test.ts new file mode 100644 index 000000000..3a28fdc22 --- /dev/null +++ b/frontend/lib/api/usePollingStationDataEntry.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test, assert } from "vitest"; +import { renderHook, waitFor, Providers } from "app/test/unit"; +import { usePollingStationDataEntry } from "./usePollingStationDataEntry"; +import { overrideOnce } from "app/test/unit"; + +describe("useApiRequest", () => { + test("it renders", async () => { + overrideOnce("post", "/v1/api/polling_stations/:id/data_entries/:entry_number", 200, { + ok: true, + }); + + const { result } = renderHook( + () => + usePollingStationDataEntry({ + id: 1, + entry_number: 1, + }), + { wrapper: Providers }, + ); + + const [doSubmit] = result.current; + doSubmit({ + data: { + voters_counts: { + poll_card_count: 1, + proxy_certificate_count: 2, + voter_card_count: 3, + total_admitted_voters_count: 4, + }, + votes_counts: { + votes_candidates_counts: 5, + blank_votes_count: 6, + invalid_votes_count: 7, + total_votes_cast_count: 8, + }, + }, + }); + + await waitFor(() => { + const [, { data }] = result.current; + expect(data).not.toBe(null); + }); + + const [, { data }] = result.current; + assert(data !== null, "data is not null"); + expect(data.ok).toBe(true); + }); +}); diff --git a/frontend/lib/api/usePollingStationDataEntry.ts b/frontend/lib/api/usePollingStationDataEntry.ts new file mode 100644 index 000000000..0287ef397 --- /dev/null +++ b/frontend/lib/api/usePollingStationDataEntry.ts @@ -0,0 +1,23 @@ +import * as React from "react"; +import { useApiRequest } from "./useApiRequest"; +import { + POLLING_STATION_DATA_ENTRY_REQUEST_BODY, + POLLING_STATION_DATA_ENTRY_REQUEST_PARAMS, + POLLING_STATION_DATA_ENTRY_REQUEST_PATH, +} from "./gen/openapi"; + +//TEMP +type ResponseData = { + ok: boolean; +}; + +export function usePollingStationDataEntry(params: POLLING_STATION_DATA_ENTRY_REQUEST_PARAMS) { + const path = React.useMemo(() => { + const result: POLLING_STATION_DATA_ENTRY_REQUEST_PATH = `/api/polling_stations/${params.id}/data_entries/${params.entry_number}`; + return result; + }, [params]); + + return useApiRequest({ + path, + }); +} diff --git a/frontend/lib/ui/InputGrid/inputgrid.e2e.ts b/frontend/lib/ui/InputGrid/InputGrid.e2e.ts similarity index 95% rename from frontend/lib/ui/InputGrid/inputgrid.e2e.ts rename to frontend/lib/ui/InputGrid/InputGrid.e2e.ts index 79dd32c76..79e336c08 100644 --- a/frontend/lib/ui/InputGrid/inputgrid.e2e.ts +++ b/frontend/lib/ui/InputGrid/InputGrid.e2e.ts @@ -2,7 +2,7 @@ import { Locator, test as base, expect } from "@playwright/test"; const test = base.extend<{ gridPage: Locator }>({ gridPage: async ({ page }, use) => { - await page.goto("http://localhost:61000/?story=inputgrid--default-grid"); + await page.goto("http://localhost:61000/?story=input-grid--default-grid"); const main = page.locator("main.ladle-main"); const grid = main.locator("table"); await grid.waitFor(); diff --git a/frontend/lib/ui/InputGrid/inputgrid.module.css b/frontend/lib/ui/InputGrid/InputGrid.module.css similarity index 100% rename from frontend/lib/ui/InputGrid/inputgrid.module.css rename to frontend/lib/ui/InputGrid/InputGrid.module.css diff --git a/frontend/lib/ui/InputGrid/inputgrid.stories.tsx b/frontend/lib/ui/InputGrid/InputGrid.stories.tsx similarity index 100% rename from frontend/lib/ui/InputGrid/inputgrid.stories.tsx rename to frontend/lib/ui/InputGrid/InputGrid.stories.tsx diff --git a/frontend/lib/ui/InputGrid/inputgrid.test.tsx b/frontend/lib/ui/InputGrid/InputGrid.test.tsx similarity index 100% rename from frontend/lib/ui/InputGrid/inputgrid.test.tsx rename to frontend/lib/ui/InputGrid/InputGrid.test.tsx diff --git a/frontend/lib/ui/InputGrid/InputGrid.tsx b/frontend/lib/ui/InputGrid/InputGrid.tsx index eed5d0444..cebf30906 100644 --- a/frontend/lib/ui/InputGrid/InputGrid.tsx +++ b/frontend/lib/ui/InputGrid/InputGrid.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import cls from "./inputgrid.module.css"; +import cls from "./InputGrid.module.css"; import { cn, domtoren } from "@kiesraad/util"; export interface InputGridProps { diff --git a/frontend/lib/util/hook/usePositiveNumberInputMask.test.ts b/frontend/lib/util/hook/usePositiveNumberInputMask.test.ts index 6ab8ce4fa..e2fe526a2 100644 --- a/frontend/lib/util/hook/usePositiveNumberInputMask.test.ts +++ b/frontend/lib/util/hook/usePositiveNumberInputMask.test.ts @@ -65,4 +65,13 @@ describe("useInputMask", () => { const { result } = renderHook(() => usePositiveNumberInputMask()); expect(result.current.format(input)).equals("0"); }); + + test("reverse format", () => { + const { result } = renderHook(() => usePositiveNumberInputMask()); + + const testNumber = 12345; + const formatted = result.current.format(testNumber); + const deformatted = result.current.deformat(formatted); + expect(deformatted).equals(testNumber); + }); }); diff --git a/frontend/lib/util/hook/usePositiveNumberInputMask.ts b/frontend/lib/util/hook/usePositiveNumberInputMask.ts index 2e8df1d71..d57384909 100644 --- a/frontend/lib/util/hook/usePositiveNumberInputMask.ts +++ b/frontend/lib/util/hook/usePositiveNumberInputMask.ts @@ -1,9 +1,11 @@ import * as React from "react"; export type FormatFunc = (s: string | number | null | undefined) => string; +export type DefomatFunc = (s: string) => number; export interface UsePositiveNumberInputMaskReturn { format: FormatFunc; + deformat: DefomatFunc; register: () => { onChange: React.ChangeEventHandler; onLoad: React.ChangeEventHandler; @@ -26,6 +28,14 @@ export function usePositiveNumberInputMask(): UsePositiveNumberInputMaskReturn { return result; }, []); + const deformat: DefomatFunc = React.useCallback((s: string) => { + const seperator = numberFormatter.format(11111).replace(/\p{Number}/gu, ""); + const escapedSeparator = seperator.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); + + const cleaned = s.replace(new RegExp(escapedSeparator, "g"), ""); + return parseInt(cleaned, 10); + }, []); + const onChange: React.ChangeEventHandler = React.useCallback( (event) => { //remove all non numbers @@ -55,6 +65,7 @@ export function usePositiveNumberInputMask(): UsePositiveNumberInputMaskReturn { return { format, + deformat, register, }; } diff --git a/frontend/package.json b/frontend/package.json index 8c9527cbd..4b2a25ecf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,12 +7,13 @@ "node": ">=20.11.1" }, "scripts": { - "dev": "API_MODE=msw vite", + "dev": "API_MODE=mock vite", + "dev:server": "API_MODE=local vite", "build": "tsc && vite build", "build:msw": "API_MODE=msw vite build && cp mockServiceWorker.js dist/", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", - "test": "vitest", + "test": "vitest ", "e2e": "playwright test", "e2eui": "playwright test --ui", "ladle": "ladle serve", diff --git a/frontend/scripts/gen_openapi_types.ts b/frontend/scripts/gen_openapi_types.ts index 5a686c8a5..68917b8ff 100644 --- a/frontend/scripts/gen_openapi_types.ts +++ b/frontend/scripts/gen_openapi_types.ts @@ -73,7 +73,7 @@ function addPath(path: string, v: PathsObject | undefined) { }); } result.push("}"); - result.push(`export type ${id}_REQUEST_PATH = \`${requestPath};\``); + result.push(`export type ${id}_REQUEST_PATH = \`${requestPath}\`;`); if (post.requestBody) { if ("$ref" in post.requestBody) { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 2a50a9970..a38543b89 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,6 +4,9 @@ import react from "@vitejs/plugin-react-swc"; import tsConfig from "./tsconfig.json"; import pkgjson from "./package.json"; +const apiMode = process.env.API_MODE || "mock"; +const apiHost = process.env.API_HOST || apiMode === "mock" ? "" : "http://localhost:8080"; + // https://vitejs.dev/config/ export default defineConfig(() => ({ build: { @@ -13,8 +16,9 @@ export default defineConfig(() => ({ // minify: false, // uncomment for debugging }, define: { - "process.env.MSW": true, + "process.env.MSW": apiMode === "mock", "process.env.VERSION": JSON.stringify(pkgjson.version), + "process.env.API_HOST": JSON.stringify(apiHost), }, optimizeDeps: { exclude: ["msw"] }, plugins: [react()], @@ -35,6 +39,16 @@ export default defineConfig(() => ({ }, }, }, + server: { + port: 3000, + proxy: { + "/v1": { + target: apiHost, + changeOrigin: true, + rewrite: (path) => path.replace(/^\/v1/, ""), + }, + }, + }, test: { environment: "jsdom", setupFiles: ["app/test/unit/setup.ts"],