diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 14eaa179f..854834a3c 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -8,7 +8,7 @@ module.exports = { "plugin:jsx-a11y/recommended", "prettier", ], - ignorePatterns: ["dist", ".eslintrc.cjs", "!.ladle/", "gen"], + ignorePatterns: ["dist", ".eslintrc.cjs", "!.ladle/"], parser: "@typescript-eslint/parser", plugins: ["react-refresh", "jsx-a11y", "prettier", "@typescript-eslint"], rules: { diff --git a/frontend/app/component/form/candidates_votes_form/CandidatesVotesForm.test.tsx b/frontend/app/component/form/candidates_votes_form/CandidatesVotesForm.test.tsx index 8fa71b0d2..0027ef607 100644 --- a/frontend/app/component/form/candidates_votes_form/CandidatesVotesForm.test.tsx +++ b/frontend/app/component/form/candidates_votes_form/CandidatesVotesForm.test.tsx @@ -1,168 +1,400 @@ -import { overrideOnce, render, screen } from "app/test/unit"; +import { getUrlMethodAndBody, overrideOnce, render, screen } from "app/test/unit"; import { userEvent } from "@testing-library/user-event"; import { describe, expect, test, vi, afterEach } from "vitest"; +import { + Election, + POLLING_STATION_DATA_ENTRY_REQUEST_BODY, + PoliticalGroup, + PollingStationFormController, +} from "@kiesraad/api"; +import { electionMock, politicalGroupMock } from "@kiesraad/api-mocks"; + import { CandidatesVotesForm } from "./CandidatesVotesForm"; -import { politicalGroupMockData } from "../../../../lib/api-mocks/ElectionMockData.ts"; + +const Component = ( + + + +); + +const rootRequest: POLLING_STATION_DATA_ENTRY_REQUEST_BODY = { + data: { + political_group_votes: electionMock.political_groups.map((group) => ({ + number: group.number, + total: 0, + candidate_votes: group.candidates.map((candidate) => ({ + number: candidate.number, + votes: 0, + })), + })), + differences_counts: { + more_ballots_count: 0, + fewer_ballots_count: 0, + unreturned_ballots_count: 0, + too_few_ballots_handed_out_count: 0, + too_many_ballots_handed_out_count: 0, + other_explanation_count: 0, + no_explanation_count: 0, + }, + voters_counts: { + poll_card_count: 0, + proxy_certificate_count: 0, + voter_card_count: 0, + total_admitted_voters_count: 0, + }, + votes_counts: { + votes_candidates_counts: 0, + blank_votes_count: 0, + invalid_votes_count: 0, + total_votes_cast_count: 0, + }, + }, +}; describe("Test CandidatesVotesForm", () => { afterEach(() => { vi.restoreAllMocks(); // ToDo: tests pass without this, so not needed? }); + describe("CandidatesVotesForm user interactions", () => { + test("hitting enter key does not result in api call", async () => { + const spy = vi.spyOn(global, "fetch"); - test("hitting enter key does not result in api call", async () => { - const spy = vi.spyOn(global, "fetch"); - - const user = userEvent.setup(); - render(); - - const candidate1 = screen.getByTestId("list1-candidate1"); - await user.clear(candidate1); - await user.type(candidate1, "12345"); - expect(candidate1).toHaveValue("12.345"); + const user = userEvent.setup(); + render(Component); - await user.keyboard("{enter}"); + const candidate1 = screen.getByTestId("candidate_votes-0.votes"); + await user.type(candidate1, "12345"); + expect(candidate1).toHaveValue("12.345"); - expect(spy).not.toHaveBeenCalled(); - }); + await user.keyboard("{enter}"); - test("Form field entry and keybindings", async () => { - overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 200, { - message: "Data saved", - saved: true, - validation_results: { errors: [], warnings: [] }, + expect(spy).not.toHaveBeenCalled(); }); - const user = userEvent.setup(); + test("Form field entry and keybindings", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 200, { + message: "Data saved", + saved: true, + validation_results: { errors: [], warnings: [] }, + }); + + const user = userEvent.setup(); - render(); + render(Component); - const candidate1 = screen.getByTestId("list1-candidate1"); - expect(candidate1).toHaveFocus(); - await user.clear(candidate1); - await user.type(candidate1, "12345"); - expect(candidate1).toHaveValue("12.345"); + const candidate1 = screen.getByTestId("candidate_votes-0.votes"); + expect(candidate1).toHaveFocus(); + await user.type(candidate1, "12345"); + expect(candidate1).toHaveValue("12.345"); - await user.keyboard("{enter}"); + await user.keyboard("{enter}"); - const candidate2 = screen.getByTestId("list1-candidate2"); - expect(candidate2).toHaveFocus(); - await user.clear(candidate2); - await user.type(candidate2, "6789"); - expect(candidate2).toHaveValue("6.789"); + const candidate2 = screen.getByTestId("candidate_votes-1.votes"); + expect(candidate2).toHaveFocus(); + await user.type(candidate2, "6789"); + expect(candidate2).toHaveValue("6.789"); - await user.keyboard("{enter}"); + await user.keyboard("{enter}"); - const candidate3 = screen.getByTestId("list1-candidate3"); - expect(candidate3).toHaveFocus(); - await user.clear(candidate3); - await user.type(candidate3, "123"); - expect(candidate3).toHaveValue("123"); + const candidate3 = screen.getByTestId("candidate_votes-2.votes"); + expect(candidate3).toHaveFocus(); + await user.type(candidate3, "123"); + expect(candidate3).toHaveValue("123"); - await user.keyboard("{enter}"); + await user.keyboard("{enter}"); - const candidate4 = screen.getByTestId("list1-candidate4"); - expect(candidate4).toHaveFocus(); - await user.clear(candidate4); - await user.paste("4242"); - expect(candidate4).toHaveValue("4.242"); + const candidate4 = screen.getByTestId("candidate_votes-3.votes"); + expect(candidate4).toHaveFocus(); + await user.paste("4242"); + expect(candidate4).toHaveValue("4.242"); - await user.keyboard("{enter}"); + await user.keyboard("{enter}"); - const candidate5 = screen.getByTestId("list1-candidate5"); - expect(candidate5).toHaveFocus(); - await user.clear(candidate5); - await user.type(candidate5, "12"); - expect(candidate5).toHaveValue("12"); + const candidate5 = screen.getByTestId("candidate_votes-4.votes"); + expect(candidate5).toHaveFocus(); + await user.type(candidate5, "12"); + expect(candidate5).toHaveValue("12"); - await user.keyboard("{enter}"); + await user.keyboard("{enter}"); - const candidate6 = screen.getByTestId("list1-candidate6"); - expect(candidate6).toHaveFocus(); - await user.clear(candidate6); - // Test if maxLength on field works - await user.type(candidate6, "1000000000"); - expect(candidate6).toHaveValue("100.000.000"); + const candidate6 = screen.getByTestId("candidate_votes-5.votes"); + expect(candidate6).toHaveFocus(); + // Test if maxLength on field works + await user.type(candidate6, "1000000000"); + expect(candidate6).toHaveValue("100.000.000"); - await user.keyboard("{enter}"); + await user.keyboard("{enter}"); - const candidate7 = screen.getByTestId("list1-candidate7"); - expect(candidate7).toHaveFocus(); - await user.clear(candidate7); - await user.type(candidate7, "3"); - expect(candidate7).toHaveValue("3"); + const candidate7 = screen.getByTestId("candidate_votes-6.votes"); + expect(candidate7).toHaveFocus(); + await user.type(candidate7, "3"); + expect(candidate7).toHaveValue("3"); - await user.keyboard("{enter}"); + await user.keyboard("{enter}"); - const total = screen.getByTestId("list1-total"); - await user.click(total); - expect(total).toHaveFocus(); - await user.clear(total); - await user.type(total, "555"); - expect(total).toHaveValue("555"); + const total = screen.getByTestId("total"); + await user.click(total); + expect(total).toHaveFocus(); + await user.type(total, "555"); + expect(total).toHaveValue("555"); - const submitButton = screen.getByRole("button", { name: "Volgende" }); - await user.click(submitButton); + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); - // const result = await screen.findByTestId("result"); - // - // expect(result).toHaveTextContent(/^Success$/); + const result = await screen.findByTestId("result"); + expect(result).toHaveTextContent(/^Success$/); + }); }); - // TODO: Add tests once submit is added - describe("VotersAndVotesForm Api call", () => { - test.skip("VotersAndVotesForm request body is equal to the form data", async () => { + describe("CandidatesVotesForm API request and response", () => { + test("CandidateVotesForm request body is equal to the form data", async () => { const spy = vi.spyOn(global, "fetch"); - const expectedRequest = {}; + const politicalGroupMockData: PoliticalGroup = { + number: 1, + name: "Lijst 1 - Vurige Vleugels Partij", + candidates: [ + { + number: 1, + initials: "E.", + first_name: "Eldor", + last_name: "Zilverlicht", + locality: "Amsterdam", + }, + { + number: 2, + initials: "G.", + first_name: "Grom", + last_name: "Donderbrul", + locality: "Rotterdam", + }, + ], + }; + + const electionMockData: Election = { + id: 1, + name: "Municipal Election", + category: "Municipal", + election_date: "2024-11-30", + nomination_date: "2024-11-01", + political_groups: [ + politicalGroupMockData, + { + number: 2, + name: "Lijst 2 - Wijzen van Water en Wind", + candidates: [ + { + number: 1, + initials: "A.", + first_name: "Alice", + last_name: "Foo", + locality: "Amsterdam", + gender: "Female", + }, + { + number: 2, + initials: "C.", + first_name: "Charlie", + last_name: "Doe", + locality: "Rotterdam", + }, + ], + }, + ], + }; + + const electionMock = electionMockData as Required; + const politicalGroupMock = politicalGroupMockData as Required; + + const Component = ( + + + + ); + + const expectedRequest = { + data: { + ...rootRequest.data, + political_group_votes: [ + { + number: 1, + total: 10, + candidate_votes: [ + { + number: 1, + votes: 5, + }, + { + number: 2, + votes: 5, + }, + ], + }, + { + number: 2, + total: 0, + candidate_votes: [ + { + number: 1, + votes: 0, + }, + { + number: 2, + votes: 0, + }, + ], + }, + ], + }, + }; const user = userEvent.setup(); - render(); + render(Component); + + await user.type( + screen.getByTestId("candidate_votes-0.votes"), + expectedRequest.data.political_group_votes[0]?.candidate_votes[0]?.votes.toString() ?? "0", + ); + + await user.type( + screen.getByTestId("candidate_votes-1.votes"), + expectedRequest.data.political_group_votes[0]?.candidate_votes[1]?.votes.toString() ?? "0", + ); + + await user.type( + screen.getByTestId("total"), + expectedRequest.data.political_group_votes[0]?.total.toString() ?? "0", + ); const submitButton = screen.getByRole("button", { name: "Volgende" }); await user.click(submitButton); - expect(spy).toHaveBeenCalledWith("http://testhost/v1/api/polling_stations/1/data_entries/1", { - method: "POST", - body: JSON.stringify(expectedRequest), - headers: { - "Content-Type": "application/json", - }, - }); + expect(spy).toHaveBeenCalled(); + const { url, method, body } = getUrlMethodAndBody(spy.mock.calls); + expect(url).toEqual("http://testhost/v1/api/polling_stations/1/data_entries/1"); + expect(method).toEqual("POST"); + expect(body).toEqual(expectedRequest); const result = await screen.findByTestId("result"); expect(result).toHaveTextContent(/^Success$/); }); - }); - test.skip("422 response results in display of error message", async () => { - overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 422, { - message: "422 error from mock", + test("422 response results in display of error message", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 422, { + message: "422 error from mock", + }); + + const user = userEvent.setup(); + + render(Component); + + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + const feedbackServerError = await screen.findByTestId("feedback-server-error"); + expect(feedbackServerError).toHaveTextContent(/^Error422 error from mock$/); }); - const user = userEvent.setup(); + test("500 response results in display of error message", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 500, { + message: "500 error from mock", + errorCode: "500_ERROR", + }); + + const user = userEvent.setup(); - render(); + render(Component); - const submitButton = screen.getByRole("button", { name: "Volgende" }); - await user.click(submitButton); - const result = await screen.findByTestId("result"); - expect(result).toHaveTextContent(/^Error 422 error from mock$/); + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + const feedbackServerError = await screen.findByTestId("feedback-server-error"); + expect(feedbackServerError).toHaveTextContent(/^Error500 error from mock$/); + }); }); - test.skip("500 response results in display of error message", async () => { - overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 500, { - message: "500 error from mock", - errorCode: "500_ERROR", + describe("CandidatesVotesForm errors", () => { + test("F.01 Invalid value", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 422, { + error: + "Failed to deserialize the JSON body into the target type: data.political_group_votes[0].total: invalid value: integer `-3`, expected u32 at line 1 column 61", + }); + + const user = userEvent.setup(); + + render(Component); + + // Since the component does not allow to input invalid values such as -3, + // not inputting any values and just clicking the submit button. + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + + const feedbackServerError = await screen.findByTestId("feedback-server-error"); + expect(feedbackServerError).toHaveTextContent(/^Error$/); + expect(screen.queryByTestId("feedback-warning")).toBeNull(); + expect(screen.queryByTestId("feedback-error")).toBeNull(); }); - const user = userEvent.setup(); + test("F.31 IncorrectTotal group total", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 200, { + saved: true, + message: "Data entry saved successfully", + validation_results: { + errors: [ + { + fields: ["data.political_group_votes[0].total"], + code: "IncorrectTotal", + }, + ], + warnings: [], + }, + }); + + render(Component); + const user = userEvent.setup(); - render(); + await user.type(screen.getByTestId("candidate_votes-0.votes"), "1"); + await user.type(screen.getByTestId("candidate_votes-1.votes"), "2"); + await user.type(screen.getByTestId("total"), "10"); - const submitButton = screen.getByRole("button", { name: "Volgende" }); - await user.click(submitButton); - const result = await screen.findByTestId("result"); - expect(result).toHaveTextContent(/^Error 500_ERROR 500 error from mock$/); + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + + const feedbackError = await screen.findByTestId("feedback-error"); + expect(feedbackError).toHaveTextContent(/^IncorrectTotal$/); + expect(screen.queryByTestId("feedback-warning")).toBeNull(); + expect(screen.queryByTestId("server-feedback-error")).toBeNull(); + }); + }); + + describe("CandidatesVotesForm warnings", () => { + test("Warnings can be displayed", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 200, { + saved: true, + message: "Data entry saved successfully", + validation_results: { + errors: [], + warnings: [ + { + fields: ["data.political_group_votes[0].total"], + code: "NotAnActualWarning", + }, + ], + }, + }); + + const user = userEvent.setup(); + + render(Component); + + // Since no warnings exist for the fields on this page, + // not inputting any values and just clicking submit. + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + + const feedbackWarning = await screen.findByTestId("feedback-warning"); + expect(feedbackWarning).toHaveTextContent(/^NotAnActualWarning$/); + expect(screen.queryByTestId("feedback-server-error")).toBeNull(); + expect(screen.queryByTestId("feedback-error")).toBeNull(); + }); }); }); diff --git a/frontend/app/component/form/candidates_votes_form/CandidatesVotesForm.tsx b/frontend/app/component/form/candidates_votes_form/CandidatesVotesForm.tsx index e9f826314..3440030ca 100644 --- a/frontend/app/component/form/candidates_votes_form/CandidatesVotesForm.tsx +++ b/frontend/app/component/form/candidates_votes_form/CandidatesVotesForm.tsx @@ -1,12 +1,19 @@ import * as React from "react"; -import { useNavigate } from "react-router-dom"; +import { useBlocker } from "react-router-dom"; -import { PoliticalGroup } from "@kiesraad/api"; -import { BottomBar, Button, InputGrid } from "@kiesraad/ui"; +import { + CandidateVotes, + PoliticalGroup, + PoliticalGroupVotes, + useErrorsAndWarnings, + usePoliticalGroup, +} from "@kiesraad/api"; +import { BottomBar, Button, Feedback, InputGrid, InputGridRow } from "@kiesraad/ui"; import { usePositiveNumberInputMask, usePreventFormEnterSubmit } from "@kiesraad/util"; interface FormElements extends HTMLFormControlsCollection { - list_total: HTMLInputElement; + total: HTMLInputElement; + "candidatevotes[]": HTMLInputElement[]; } interface CandidatesVotesFormElement extends HTMLFormElement { @@ -18,26 +25,100 @@ export interface CandidatesVotesFormProps { } export function CandidatesVotesForm({ group }: CandidatesVotesFormProps) { - const navigate = useNavigate(); - const { register } = usePositiveNumberInputMask(); - const formRef = React.useRef(null); + const { register, format, deformat, warnings: inputMaskWarnings } = usePositiveNumberInputMask(); + const formRef = React.useRef(null); + const { + sectionValues, + errors, + warnings, + setSectionValues, + loading, + serverError, + isCalled, + setTemporaryCache, + } = usePoliticalGroup(group.number); usePreventFormEnterSubmit(formRef); - const candidates = React.useMemo(() => { - return group.candidates.map((candidate) => { - return `${candidate.last_name}, ${candidate.initials} (${candidate.first_name})`; - }); - }, [group]); + const getValues = React.useCallback( + (elements: CandidatesVotesFormElement["elements"]): PoliticalGroupVotes => { + const candidate_votes: CandidateVotes[] = []; + for (const el of elements["candidatevotes[]"]) { + candidate_votes.push({ + number: candidateNumberFromElement(el), + votes: deformat(el.value), + }); + } + return { + number: group.number, + total: deformat(elements.total.value), + candidate_votes: candidate_votes, + }; + }, + [deformat, group], + ); + + const errorsAndWarnings = useErrorsAndWarnings(errors, warnings, inputMaskWarnings); + + //const blocker = useBlocker() use const blocker to render confirmation UI. + useBlocker(() => { + if (formRef.current && !isCalled) { + const elements = formRef.current.elements; + const values = getValues(elements); + setTemporaryCache({ + key: "political_group_votes", + id: group.number, + data: values, + }); + } + return false; + }); function handleSubmit(event: React.FormEvent) { event.preventDefault(); - navigate(`../list/${group.number + 1}`); + const elements = event.currentTarget.elements; + setSectionValues(getValues(elements)); } + const hasValidationError = errors.length > 0; + const hasValidationWarning = warnings.length > 0; + const success = isCalled && !hasValidationError && !hasValidationWarning && !loading; return (
+ {/* Temporary while not navigating through form sections */} + {success &&
Success
}

{group.name}

+ {serverError && ( + +
+

Error

+

{serverError.message}

+
+
+ )} + {hasValidationError && ( + +
+
    + {errors.map((error, n) => ( +
  • {error.code}
  • + ))} +
+
+
+ )} + + {hasValidationWarning && !hasValidationError && ( + +
+
    + {warnings.map((warning, n) => ( +
  • {warning.code}
  • + ))} +
+
+
+ )} Nummer @@ -45,39 +126,41 @@ export function CandidatesVotesForm({ group }: CandidatesVotesFormProps) { Kandidaat - {candidates.map((candidate, index) => { + {group.candidates.map((candidate, index) => { const addSeparator = (index + 1) % 25 == 0; + const defaultValue = sectionValues?.candidate_votes[index]?.votes || ""; return ( - - {index + 1} - - - - {candidate} - + field={`${index + 1}`} + name="candidatevotes[]" + id={`candidate_votes-${candidate.number - 1}.votes`} + title={`${candidate.last_name}, ${candidate.initials} (${candidate.first_name})`} + errorsAndWarnings={errorsAndWarnings} + inputProps={register()} + format={format} + addSeparator={addSeparator} + defaultValue={defaultValue} + isFocused={index === 0} + /> ); })} - - - - - - Totaal lijst {group.number} - + - SHIFT + Enter @@ -85,3 +168,13 @@ export function CandidatesVotesForm({ group }: CandidatesVotesFormProps) { ); } + +function candidateNumberFromElement(el: HTMLInputElement) { + const id = el.id; + const bits = id.split("-"); + const numberString = bits[bits.length - 1]; + if (numberString) { + return parseInt(numberString) + 1; + } + return 0; +} diff --git a/frontend/app/component/form/differences/DifferencesForm.test.tsx b/frontend/app/component/form/differences/DifferencesForm.test.tsx index 483bc8d7e..d734e6e10 100644 --- a/frontend/app/component/form/differences/DifferencesForm.test.tsx +++ b/frontend/app/component/form/differences/DifferencesForm.test.tsx @@ -2,127 +2,155 @@ * @vitest-environment jsdom */ -import { overrideOnce, render, screen, fireEvent } from "app/test/unit"; +import { overrideOnce, render, screen, getUrlMethodAndBody } from "app/test/unit"; import { userEvent } from "@testing-library/user-event"; import { describe, expect, test, vi, afterEach } from "vitest"; + +import { + POLLING_STATION_DATA_ENTRY_REQUEST_BODY, + PollingStationFormController, +} from "@kiesraad/api"; +import { electionMock } from "@kiesraad/api-mocks"; import { DifferencesForm } from "./DifferencesForm"; +const Component = ( + + + +); + +const rootRequest: POLLING_STATION_DATA_ENTRY_REQUEST_BODY = { + data: { + political_group_votes: electionMock.political_groups.map((group) => ({ + number: group.number, + total: 0, + candidate_votes: group.candidates.map((candidate) => ({ + number: candidate.number, + votes: 0, + })), + })), + differences_counts: { + more_ballots_count: 0, + fewer_ballots_count: 0, + unreturned_ballots_count: 0, + too_few_ballots_handed_out_count: 0, + too_many_ballots_handed_out_count: 0, + other_explanation_count: 0, + no_explanation_count: 0, + }, + voters_counts: { + poll_card_count: 0, + proxy_certificate_count: 0, + voter_card_count: 0, + total_admitted_voters_count: 0, + }, + votes_counts: { + votes_candidates_counts: 0, + blank_votes_count: 0, + invalid_votes_count: 0, + total_votes_cast_count: 0, + }, + }, +}; + describe("Test DifferencesForm", () => { afterEach(() => { vi.restoreAllMocks(); // ToDo: tests pass without this, so not needed? }); - test("hitting enter key does not result in api call", async () => { - const spy = vi.spyOn(global, "fetch"); - - const user = userEvent.setup(); + describe("DifferencesForm user interactions", () => { + test("hitting enter key does not result in api call", async () => { + const spy = vi.spyOn(global, "fetch"); - render(); + const user = userEvent.setup(); - const pollCards = screen.getByTestId("more_ballots_count"); - await user.clear(pollCards); - await user.type(pollCards, "12345"); - expect(pollCards).toHaveValue("12.345"); + render(Component); - await user.keyboard("{enter}"); + const moreBallotsCount = screen.getByTestId("more_ballots_count"); + await user.type(moreBallotsCount, "12345"); + expect(moreBallotsCount).toHaveValue("12.345"); - expect(spy).not.toHaveBeenCalled(); - }); + await user.keyboard("{enter}"); - test("Form field entry and keybindings", async () => { - overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 200, { - message: "Data saved", - saved: true, - validation_results: { errors: [], warnings: [] }, + expect(spy).not.toHaveBeenCalled(); }); - const user = userEvent.setup(); + test("Form field entry and keybindings", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 200, { + message: "Data saved", + saved: true, + validation_results: { errors: [], warnings: [] }, + }); - render(); + const user = userEvent.setup(); - const moreBallotsCount = screen.getByTestId("more_ballots_count"); - expect(moreBallotsCount).toHaveFocus(); - await user.clear(moreBallotsCount); - await user.type(moreBallotsCount, "12345"); - expect(moreBallotsCount).toHaveValue("12.345"); + render(Component); - await user.keyboard("{enter}"); + const moreBallotsCount = screen.getByTestId("more_ballots_count"); + expect(moreBallotsCount).toHaveFocus(); + await user.type(moreBallotsCount, "12345"); + expect(moreBallotsCount).toHaveValue("12.345"); - const fewerBallotsCount = screen.getByTestId("fewer_ballots_count"); - expect(fewerBallotsCount).toHaveFocus(); - await user.clear(fewerBallotsCount); - await user.paste("6789"); - expect(fewerBallotsCount).toHaveValue("6.789"); + await user.keyboard("{enter}"); - await user.keyboard("{enter}"); + const fewerBallotsCount = screen.getByTestId("fewer_ballots_count"); + expect(fewerBallotsCount).toHaveFocus(); + await user.paste("6789"); + expect(fewerBallotsCount).toHaveValue("6.789"); - const unreturnedBallotsCount = screen.getByTestId("unreturned_ballots_count"); - expect(unreturnedBallotsCount).toHaveFocus(); - await user.clear(unreturnedBallotsCount); - await user.type(unreturnedBallotsCount, "123"); - expect(unreturnedBallotsCount).toHaveValue("123"); + await user.keyboard("{enter}"); - await user.keyboard("{enter}"); + const unreturnedBallotsCount = screen.getByTestId("unreturned_ballots_count"); + expect(unreturnedBallotsCount).toHaveFocus(); + await user.type(unreturnedBallotsCount, "123"); + expect(unreturnedBallotsCount).toHaveValue("123"); - const tooFewBallotsHandedOutCount = screen.getByTestId("too_few_ballots_handed_out_count"); - expect(tooFewBallotsHandedOutCount).toHaveFocus(); - await user.clear(tooFewBallotsHandedOutCount); - await user.paste("4242"); - expect(tooFewBallotsHandedOutCount).toHaveValue("4.242"); + await user.keyboard("{enter}"); - await user.keyboard("{enter}"); + const tooFewBallotsHandedOutCount = screen.getByTestId("too_few_ballots_handed_out_count"); + expect(tooFewBallotsHandedOutCount).toHaveFocus(); + await user.paste("4242"); + expect(tooFewBallotsHandedOutCount).toHaveValue("4.242"); - const tooManyBallotsHandedOutCount = screen.getByTestId("too_many_ballots_handed_out_count"); - expect(tooManyBallotsHandedOutCount).toHaveFocus(); - await user.clear(tooManyBallotsHandedOutCount); - await user.type(tooManyBallotsHandedOutCount, "12"); - expect(tooManyBallotsHandedOutCount).toHaveValue("12"); + await user.keyboard("{enter}"); - await user.keyboard("{enter}"); + const tooManyBallotsHandedOutCount = screen.getByTestId("too_many_ballots_handed_out_count"); + expect(tooManyBallotsHandedOutCount).toHaveFocus(); + await user.type(tooManyBallotsHandedOutCount, "12"); + expect(tooManyBallotsHandedOutCount).toHaveValue("12"); - const otherExplanationCount = screen.getByTestId("other_explanation_count"); - expect(otherExplanationCount).toHaveFocus(); - await user.clear(otherExplanationCount); - // Test if maxLength on field works - await user.type(otherExplanationCount, "1000000000"); - expect(otherExplanationCount).toHaveValue("100.000.000"); + await user.keyboard("{enter}"); - await user.keyboard("{enter}"); + const otherExplanationCount = screen.getByTestId("other_explanation_count"); + expect(otherExplanationCount).toHaveFocus(); + // Test if maxLength on field works + await user.type(otherExplanationCount, "1000000000"); + expect(otherExplanationCount).toHaveValue("100.000.000"); - const noExplanationCount = screen.getByTestId("no_explanation_count"); - expect(noExplanationCount).toHaveFocus(); - await user.clear(noExplanationCount); - await user.type(noExplanationCount, "3"); - expect(noExplanationCount).toHaveValue("3"); + await user.keyboard("{enter}"); - await user.keyboard("{enter}"); + const noExplanationCount = screen.getByTestId("no_explanation_count"); + expect(noExplanationCount).toHaveFocus(); + await user.type(noExplanationCount, "3"); + expect(noExplanationCount).toHaveValue("3"); - const submitButton = screen.getByRole("button", { name: "Volgende" }); - await user.click(submitButton); + await user.keyboard("{enter}"); - const result = await screen.findByTestId("result"); + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); - expect(result).toHaveTextContent(/^Success$/); + const result = await screen.findByTestId("result"); + expect(result).toHaveTextContent(/^Success$/); + }); }); - describe("DifferencesForm Api call", () => { + describe("DifferencesForm API request and response", () => { test("DifferencesForm request body is equal to the form data", async () => { const spy = vi.spyOn(global, "fetch"); const expectedRequest = { data: { - voters_counts: { - poll_card_count: 0, - proxy_certificate_count: 0, - voter_card_count: 0, - total_admitted_voters_count: 0, - }, - votes_counts: { - votes_candidates_counts: 0, - blank_votes_count: 0, - invalid_votes_count: 0, - total_votes_cast_count: 0, - }, + ...rootRequest.data, differences_counts: { more_ballots_count: 2, fewer_ballots_count: 0, @@ -132,135 +160,139 @@ describe("Test DifferencesForm", () => { other_explanation_count: 0, no_explanation_count: 1, }, - political_group_votes: [ - { - candidate_votes: [{ number: 1, votes: 0 }], - number: 1, - total: 0, - }, - ], }, }; const user = userEvent.setup(); - const { getByTestId } = render(); + render(Component); + + await user.type( + screen.getByTestId("more_ballots_count"), + expectedRequest.data.differences_counts.more_ballots_count.toString(), + ); + await user.type( + screen.getByTestId("fewer_ballots_count"), + expectedRequest.data.differences_counts.fewer_ballots_count.toString(), + ); + await user.type( + screen.getByTestId("unreturned_ballots_count"), + expectedRequest.data.differences_counts.unreturned_ballots_count.toString(), + ); + await user.type( + screen.getByTestId("too_few_ballots_handed_out_count"), + expectedRequest.data.differences_counts.too_few_ballots_handed_out_count.toString(), + ); + await user.type( + screen.getByTestId("too_many_ballots_handed_out_count"), + expectedRequest.data.differences_counts.too_many_ballots_handed_out_count.toString(), + ); + await user.type( + screen.getByTestId("other_explanation_count"), + expectedRequest.data.differences_counts.other_explanation_count.toString(), + ); + await user.type( + screen.getByTestId("no_explanation_count"), + expectedRequest.data.differences_counts.no_explanation_count.toString(), + ); - const moreBallotsCount = getByTestId("more_ballots_count"); - fireEvent.change(moreBallotsCount, { - target: { value: expectedRequest.data.differences_counts.more_ballots_count.toString() }, - }); + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); - const fewerBallotsCount = getByTestId("fewer_ballots_count"); - fireEvent.change(fewerBallotsCount, { - target: { value: expectedRequest.data.differences_counts.fewer_ballots_count.toString() }, - }); + expect(spy).toHaveBeenCalled(); + const { url, method, body } = getUrlMethodAndBody(spy.mock.calls); + expect(url).toEqual("http://testhost/v1/api/polling_stations/1/data_entries/1"); + expect(method).toEqual("POST"); + expect(body).toEqual(expectedRequest); - const unreturnedBallotsCount = getByTestId("unreturned_ballots_count"); - fireEvent.change(unreturnedBallotsCount, { - target: { - value: expectedRequest.data.differences_counts.unreturned_ballots_count.toString(), - }, - }); - - const tooFewBallotsHandedOutCount = getByTestId("too_few_ballots_handed_out_count"); - fireEvent.change(tooFewBallotsHandedOutCount, { - target: { - value: - expectedRequest.data.differences_counts.too_few_ballots_handed_out_count.toString(), - }, - }); + const result = await screen.findByTestId("result"); + expect(result).toHaveTextContent(/^Success$/); + }); - const tooManyBallotsHandedOutCount = getByTestId("too_many_ballots_handed_out_count"); - fireEvent.change(tooManyBallotsHandedOutCount, { - target: { - value: - expectedRequest.data.differences_counts.too_many_ballots_handed_out_count.toString(), - }, + test("422 response results in display of error message", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 422, { + message: "422 error from mock", }); - const otherExplanationCount = getByTestId("other_explanation_count"); - fireEvent.change(otherExplanationCount, { - target: { - value: expectedRequest.data.differences_counts.other_explanation_count.toString(), - }, - }); + const user = userEvent.setup(); - const noExplanationCount = getByTestId("no_explanation_count"); - fireEvent.change(noExplanationCount, { - target: { value: expectedRequest.data.differences_counts.no_explanation_count.toString() }, - }); + render(Component); const submitButton = screen.getByRole("button", { name: "Volgende" }); await user.click(submitButton); + const feedbackServerError = await screen.findByTestId("feedback-server-error"); + expect(feedbackServerError).toHaveTextContent(/^Error422 error from mock$/); + }); - expect(spy).toHaveBeenCalledWith("http://testhost/v1/api/polling_stations/1/data_entries/1", { - method: "POST", - body: JSON.stringify(expectedRequest), - headers: { - "Content-Type": "application/json", - }, + test("500 response results in display of error message", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 500, { + message: "500 error from mock", }); - const result = await screen.findByTestId("result"); - expect(result).toHaveTextContent(/^Success$/); + const user = userEvent.setup(); + + render(Component); + + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + const feedbackServerError = await screen.findByTestId("feedback-server-error"); + expect(feedbackServerError).toHaveTextContent(/^Error500 error from mock$/); }); }); - test("422 response results in display of error message", async () => { - overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 422, { - message: "422 error from mock", - }); + describe("DifferencesForm errors", () => { + // TODO: Add validation test once backend validation is implemented + test.skip("Incorrect total is caught by validation", async () => { + const user = userEvent.setup(); - const user = userEvent.setup(); + render(Component); - render(); + await user.type(screen.getByTestId("more_ballots_count"), "2"); + await user.type(screen.getByTestId("fewer_ballots_count"), "0"); + await user.type(screen.getByTestId("unreturned_ballots_count"), "0"); + await user.type(screen.getByTestId("too_few_ballots_handed_out_count"), "0"); + await user.type(screen.getByTestId("too_many_ballots_handed_out_count"), "1"); + await user.type(screen.getByTestId("other_explanation_count"), "0"); + await user.type(screen.getByTestId("no_explanation_count"), "1"); - const submitButton = screen.getByRole("button", { name: "Volgende" }); - await user.click(submitButton); - const result = await screen.findByTestId("result"); - expect(result).toHaveTextContent(/^422 error from mock$/); - }); + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); - test("500 response results in display of error message", async () => { - overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 500, { - message: "500 error from mock", + const feedbackError = await screen.findByTestId("feedback-error"); + expect(feedbackError).toHaveTextContent(/^IncorrectTotal,IncorrectTotal$/); }); + }); - const user = userEvent.setup(); + describe("DifferencesForm warnings", () => { + // TODO: Unskip test once validation is implemented in frontend and backend + test.skip("Warnings can be displayed", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 200, { + saved: true, + message: "Data entry saved successfully", + validation_results: { + errors: [], + warnings: [ + { + fields: ["data.differences_counts.more_ballots_count"], + code: "NotAnActualWarning", + }, + ], + }, + }); - render(); + const user = userEvent.setup(); - const submitButton = screen.getByRole("button", { name: "Volgende" }); - await user.click(submitButton); - const result = await screen.findByTestId("result"); - expect(result).toHaveTextContent(/^500 error from mock$/); - }); + render(Component); - // TODO: Add validation once backend validation is implemented - test.skip("Incorrect total is caught by validation", async () => { - const { getByTestId } = render(); + // Since no warnings exist for the fields on this page, + // not inputting any values and just clicking submit. + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); - const setValue = (id: string, value: string | number) => { - const el = getByTestId(id); - fireEvent.change(el, { - target: { value: `${value}` }, - }); - }; - - setValue("more_ballots_count", 2); - setValue("fewer_ballots_count", 0); - setValue("unreturned_ballots_count", 0); - setValue("too_few_ballots_handed_out_count", 0); - setValue("too_many_ballots_handed_out_count", 1); - setValue("other_explanation_count", 0); - setValue("no_explanation_count", 1); - - const user = userEvent.setup(); - const submitButton = screen.getByRole("button", { name: "Volgende" }); - await user.click(submitButton); - - const result = await screen.findByTestId("error-codes"); - expect(result).toHaveTextContent(/^IncorrectTotal,IncorrectTotal$/); + const feedbackWarning = await screen.findByTestId("feedback-warning"); + expect(feedbackWarning).toHaveTextContent(/^NotAnActualWarning$/); + expect(screen.queryByTestId("feedback-server-error")).toBeNull(); + expect(screen.queryByTestId("feedback-error")).toBeNull(); + }); }); }); diff --git a/frontend/app/component/form/differences/DifferencesForm.tsx b/frontend/app/component/form/differences/DifferencesForm.tsx index d4f04ff97..e8960ec53 100644 --- a/frontend/app/component/form/differences/DifferencesForm.tsx +++ b/frontend/app/component/form/differences/DifferencesForm.tsx @@ -1,12 +1,9 @@ import * as React from "react"; +import { useBlocker } from "react-router-dom"; -import { usePollingStationDataEntry, ValidationResult, ErrorsAndWarnings } from "@kiesraad/api"; +import { useDifferences, DifferencesCounts, useErrorsAndWarnings } from "@kiesraad/api"; import { Button, InputGrid, Feedback, BottomBar, InputGridRow, useTooltip } from "@kiesraad/ui"; -import { - usePositiveNumberInputMask, - usePreventFormEnterSubmit, - fieldNameFromPath, -} from "@kiesraad/util"; +import { usePositiveNumberInputMask, usePreventFormEnterSubmit } from "@kiesraad/util"; interface FormElements extends HTMLFormControlsCollection { more_ballots_count: HTMLInputElement; @@ -32,126 +29,85 @@ export function DifferencesForm() { } = usePositiveNumberInputMask(); const formRef = React.useRef(null); usePreventFormEnterSubmit(formRef); - const [doSubmit, { data, loading, error }] = usePollingStationDataEntry({ - polling_station_id: 1, - entry_number: 1, - }); + const { + sectionValues, + setSectionValues, + loading, + errors, + warnings, + serverError, + isCalled, + setTemporaryCache, + } = useDifferences(); useTooltip({ onDismiss: resetWarnings, }); + const getValues = React.useCallback( + (elements: DifferencesFormElement["elements"]): DifferencesCounts => { + return { + more_ballots_count: deformat(elements.more_ballots_count.value), + fewer_ballots_count: deformat(elements.fewer_ballots_count.value), + unreturned_ballots_count: deformat(elements.unreturned_ballots_count.value), + too_few_ballots_handed_out_count: deformat(elements.too_few_ballots_handed_out_count.value), + too_many_ballots_handed_out_count: deformat( + elements.too_many_ballots_handed_out_count.value, + ), + other_explanation_count: deformat(elements.other_explanation_count.value), + no_explanation_count: deformat(elements.no_explanation_count.value), + }; + }, + [deformat], + ); + function handleSubmit(event: React.FormEvent) { event.preventDefault(); const elements = event.currentTarget.elements; - - doSubmit({ - data: { - voters_counts: { - poll_card_count: 0, - proxy_certificate_count: 0, - voter_card_count: 0, - total_admitted_voters_count: 0, - }, - votes_counts: { - votes_candidates_counts: 0, - blank_votes_count: 0, - invalid_votes_count: 0, - total_votes_cast_count: 0, - }, - differences_counts: { - more_ballots_count: deformat(elements.more_ballots_count.value), - fewer_ballots_count: deformat(elements.fewer_ballots_count.value), - unreturned_ballots_count: deformat(elements.unreturned_ballots_count.value), - too_few_ballots_handed_out_count: deformat( - elements.too_few_ballots_handed_out_count.value, - ), - too_many_ballots_handed_out_count: deformat( - elements.too_many_ballots_handed_out_count.value, - ), - other_explanation_count: deformat(elements.other_explanation_count.value), - no_explanation_count: deformat(elements.no_explanation_count.value), - }, - political_group_votes: [ - { - candidate_votes: [{ number: 1, votes: 0 }], - number: 1, - total: 0, - }, - ], - }, - }); + setSectionValues(getValues(elements)); } - - const errorsAndWarnings: Map = React.useMemo(() => { - const result = new Map(); - - const process = (target: keyof ErrorsAndWarnings, arr: ValidationResult[]) => { - arr.forEach((v) => { - v.fields.forEach((f) => { - const fieldName = fieldNameFromPath(f); - if (!result.has(fieldName)) { - result.set(fieldName, { errors: [], warnings: [] }); - } - const field = result.get(fieldName); - if (field) { - field[target].push({ - code: v.code, - id: fieldName, - }); - } - }); + //const blocker = useBlocker() use const blocker to render confirmation UI. + useBlocker(() => { + if (formRef.current && !isCalled) { + const elements = formRef.current.elements as DifferencesFormElement["elements"]; + const values = getValues(elements); + setTemporaryCache({ + key: "differences", + data: values, }); - }; - - if (data && data.validation_results.errors.length > 0) { - process("errors", data.validation_results.errors); } - if (data && data.validation_results.warnings.length > 0) { - process("warnings", data.validation_results.warnings); - } - - inputMaskWarnings.forEach((warning) => { - if (!result.has(warning.id)) { - result.set(warning.id, { errors: [], warnings: [] }); - } - const field = result.get(warning.id); - if (field) { - field.warnings.push(warning); - } - }); + return false; + }); - return result; - }, [data, inputMaskWarnings]); + const errorsAndWarnings = useErrorsAndWarnings(errors, warnings, inputMaskWarnings); React.useEffect(() => { - if (data) { + if (isCalled) { window.scrollTo(0, 0); } - }, [data]); - - const hasValidationError = data && data.validation_results.errors.length > 0; - const hasValidationWarning = data && data.validation_results.warnings.length > 0; + }, [isCalled]); + const hasValidationError = errors.length > 0; + const hasValidationWarning = warnings.length > 0; + const success = isCalled && !hasValidationError && !hasValidationWarning && !loading; return (
-
- {data && data.validation_results.errors.map((r) => r.code).join(",")} -
+ {/* Temporary while not navigating through form sections */} + {success &&
Success
}

Verschil tussen aantal kiezers en getelde stemmen

- {error && ( + {serverError && ( -
+

Error

-

{error.message}

+

{serverError.message}

)} {hasValidationError && ( -
+
    - {data.validation_results.errors.map((error, n) => ( + {errors.map((error, n) => (
  • {error.code}
  • ))}
@@ -159,25 +115,17 @@ export function DifferencesForm() { )} - {hasValidationWarning && ( + {hasValidationWarning && !hasValidationError && ( -
+
    - {data.validation_results.warnings.map((warning, n) => ( + {warnings.map((warning, n) => (
  • {warning.code}
  • ))}
)} - - {data && !hasValidationError && ( - -
-

Success

-
-
- )} Veld @@ -188,71 +136,78 @@ export function DifferencesForm() { 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 acf90fc34..3aed47dbe 100644 --- a/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.test.tsx +++ b/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.test.tsx @@ -2,121 +2,160 @@ * @vitest-environment jsdom */ -import { overrideOnce, render, screen, fireEvent } from "app/test/unit"; +import { overrideOnce, render, screen, getUrlMethodAndBody } from "app/test/unit"; import { userEvent } from "@testing-library/user-event"; import { describe, expect, test, vi, afterEach } from "vitest"; + +import { + POLLING_STATION_DATA_ENTRY_REQUEST_BODY, + PollingStationFormController, +} from "@kiesraad/api"; +import { electionMock } from "@kiesraad/api-mocks"; import { VotersAndVotesForm } from "./VotersAndVotesForm"; +const Component = ( + + + +); + +const rootRequest: POLLING_STATION_DATA_ENTRY_REQUEST_BODY = { + data: { + political_group_votes: electionMock.political_groups.map((group) => ({ + number: group.number, + total: 0, + candidate_votes: group.candidates.map((candidate) => ({ + number: candidate.number, + votes: 0, + })), + })), + differences_counts: { + more_ballots_count: 0, + fewer_ballots_count: 0, + unreturned_ballots_count: 0, + too_few_ballots_handed_out_count: 0, + too_many_ballots_handed_out_count: 0, + other_explanation_count: 0, + no_explanation_count: 0, + }, + voters_counts: { + poll_card_count: 0, + proxy_certificate_count: 0, + voter_card_count: 0, + total_admitted_voters_count: 0, + }, + votes_counts: { + votes_candidates_counts: 0, + blank_votes_count: 0, + invalid_votes_count: 0, + total_votes_cast_count: 0, + }, + }, +}; + describe("Test VotersAndVotesForm", () => { afterEach(() => { vi.restoreAllMocks(); // ToDo: tests pass without this, so not needed? }); - test("hitting enter key does not result in api call", async () => { - const spy = vi.spyOn(global, "fetch"); - - const user = userEvent.setup(); + describe("VotersAndVotesForm user interactions", () => { + test("hitting enter key does not result in api call", async () => { + const spy = vi.spyOn(global, "fetch"); - render(); + const user = userEvent.setup(); - const pollCards = screen.getByTestId("poll_card_count"); - await user.clear(pollCards); - await user.type(pollCards, "12345"); - expect(pollCards).toHaveValue("12.345"); + render(Component); - await user.keyboard("{enter}"); + const pollCards = screen.getByTestId("poll_card_count"); + await user.type(pollCards, "12345"); + expect(pollCards).toHaveValue("12.345"); - expect(spy).not.toHaveBeenCalled(); - }); + await user.keyboard("{enter}"); - test("Form field entry and keybindings", async () => { - overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 200, { - message: "Data saved", - saved: true, - validation_results: { errors: [], warnings: [] }, + expect(spy).not.toHaveBeenCalled(); }); - const user = userEvent.setup(); + test("Form field entry and keybindings", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 200, { + message: "Data saved", + saved: true, + validation_results: { errors: [], warnings: [] }, + }); - render(); + const user = userEvent.setup(); - const pollCards = screen.getByTestId("poll_card_count"); - expect(pollCards).toHaveFocus(); - await user.clear(pollCards); - await user.type(pollCards, "12345"); - expect(pollCards).toHaveValue("12.345"); + render(Component); - await user.keyboard("{enter}"); + const pollCards = screen.getByTestId("poll_card_count"); + expect(pollCards).toHaveFocus(); + await user.type(pollCards, "12345"); + expect(pollCards).toHaveValue("12.345"); - const proxyCertificates = screen.getByTestId("proxy_certificate_count"); - expect(proxyCertificates).toHaveFocus(); - await user.clear(proxyCertificates); - await user.paste("6789"); - expect(proxyCertificates).toHaveValue("6.789"); + await user.keyboard("{enter}"); - await user.keyboard("{enter}"); + const proxyCertificates = screen.getByTestId("proxy_certificate_count"); + expect(proxyCertificates).toHaveFocus(); + await user.paste("6789"); + expect(proxyCertificates).toHaveValue("6.789"); - const voterCards = screen.getByTestId("voter_card_count"); - expect(voterCards).toHaveFocus(); - await user.clear(voterCards); - await user.type(voterCards, "123"); - expect(voterCards).toHaveValue("123"); + await user.keyboard("{enter}"); - await user.keyboard("{enter}"); + const voterCards = screen.getByTestId("voter_card_count"); + expect(voterCards).toHaveFocus(); + await user.type(voterCards, "123"); + expect(voterCards).toHaveValue("123"); - const totalAdmittedVoters = screen.getByTestId("total_admitted_voters_count"); - expect(totalAdmittedVoters).toHaveFocus(); - await user.clear(totalAdmittedVoters); - await user.paste("4242"); - expect(totalAdmittedVoters).toHaveValue("4.242"); + await user.keyboard("{enter}"); - await user.keyboard("{enter}"); + const totalAdmittedVoters = screen.getByTestId("total_admitted_voters_count"); + expect(totalAdmittedVoters).toHaveFocus(); + await user.paste("4242"); + expect(totalAdmittedVoters).toHaveValue("4.242"); - const votesOnCandidates = screen.getByTestId("votes_candidates_counts"); - expect(votesOnCandidates).toHaveFocus(); - await user.clear(votesOnCandidates); - await user.type(votesOnCandidates, "12"); - expect(votesOnCandidates).toHaveValue("12"); + await user.keyboard("{enter}"); - await user.keyboard("{enter}"); + const votesOnCandidates = screen.getByTestId("votes_candidates_counts"); + expect(votesOnCandidates).toHaveFocus(); + await user.type(votesOnCandidates, "12"); + expect(votesOnCandidates).toHaveValue("12"); - const blankVotes = screen.getByTestId("blank_votes_count"); - expect(blankVotes).toHaveFocus(); - await user.clear(blankVotes); - // Test if maxLength on field works - await user.type(blankVotes, "1000000000"); - expect(blankVotes).toHaveValue("100.000.000"); + await user.keyboard("{enter}"); - await user.keyboard("{enter}"); + const blankVotes = screen.getByTestId("blank_votes_count"); + expect(blankVotes).toHaveFocus(); + // Test if maxLength on field works + await user.type(blankVotes, "1000000000"); + expect(blankVotes).toHaveValue("100.000.000"); - const invalidVotes = screen.getByTestId("invalid_votes_count"); - expect(invalidVotes).toHaveFocus(); - await user.clear(invalidVotes); - await user.type(invalidVotes, "3"); - expect(invalidVotes).toHaveValue("3"); + await user.keyboard("{enter}"); - await user.keyboard("{enter}"); + const invalidVotes = screen.getByTestId("invalid_votes_count"); + expect(invalidVotes).toHaveFocus(); + await user.type(invalidVotes, "3"); + expect(invalidVotes).toHaveValue("3"); - const totalVotesCast = screen.getByTestId("total_votes_cast_count"); - expect(totalVotesCast).toHaveFocus(); - await user.clear(totalVotesCast); - await user.type(totalVotesCast, "555"); - expect(totalVotesCast).toHaveValue("555"); + await user.keyboard("{enter}"); - const submitButton = screen.getByRole("button", { name: "Volgende" }); - await user.click(submitButton); + const totalVotesCast = screen.getByTestId("total_votes_cast_count"); + expect(totalVotesCast).toHaveFocus(); + await user.type(totalVotesCast, "555"); + expect(totalVotesCast).toHaveValue("555"); - const result = await screen.findByTestId("result"); + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); - expect(result).toHaveTextContent(/^Success$/); + const result = await screen.findByTestId("result"); + expect(result).toHaveTextContent(/^Success$/); + }); }); - describe("VotersAndVotesForm Api call", () => { + describe("VotersAndVotesForm API request and response", () => { test("VotersAndVotesForm request body is equal to the form data", async () => { const spy = vi.spyOn(global, "fetch"); const expectedRequest = { data: { + ...rootRequest.data, voters_counts: { poll_card_count: 1, proxy_certificate_count: 2, @@ -129,143 +168,349 @@ describe("Test VotersAndVotesForm", () => { invalid_votes_count: 6, total_votes_cast_count: 15, }, - differences_counts: { - more_ballots_count: 0, - fewer_ballots_count: 0, - unreturned_ballots_count: 0, - too_few_ballots_handed_out_count: 0, - too_many_ballots_handed_out_count: 0, - other_explanation_count: 0, - no_explanation_count: 0, - }, - political_group_votes: [ - { - candidate_votes: [{ number: 1, votes: 0 }], - number: 1, - total: 0, - }, - ], }, }; const user = userEvent.setup(); - const { getByTestId } = render(); + render(Component); + + await user.type( + screen.getByTestId("poll_card_count"), + expectedRequest.data.voters_counts.poll_card_count.toString(), + ); + await user.type( + screen.getByTestId("proxy_certificate_count"), + expectedRequest.data.voters_counts.proxy_certificate_count.toString(), + ); + await user.type( + screen.getByTestId("voter_card_count"), + expectedRequest.data.voters_counts.voter_card_count.toString(), + ); + await user.type( + screen.getByTestId("total_admitted_voters_count"), + expectedRequest.data.voters_counts.total_admitted_voters_count.toString(), + ); + + await user.type( + screen.getByTestId("votes_candidates_counts"), + expectedRequest.data.votes_counts.votes_candidates_counts.toString(), + ); + await user.type( + screen.getByTestId("blank_votes_count"), + expectedRequest.data.votes_counts.blank_votes_count.toString(), + ); + await user.type( + screen.getByTestId("invalid_votes_count"), + expectedRequest.data.votes_counts.invalid_votes_count.toString(), + ); + await user.type( + screen.getByTestId("total_votes_cast_count"), + expectedRequest.data.votes_counts.total_votes_cast_count.toString(), + ); + + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + + expect(spy).toHaveBeenCalled(); + const { url, method, body } = getUrlMethodAndBody(spy.mock.calls); + + expect(url).toEqual("http://testhost/v1/api/polling_stations/1/data_entries/1"); + expect(method).toEqual("POST"); + expect(body).toEqual(expectedRequest); - const pollCards = getByTestId("poll_card_count"); - fireEvent.change(pollCards, { - target: { value: expectedRequest.data.voters_counts.poll_card_count.toString() }, + const result = await screen.findByTestId("result"); + expect(result).toHaveTextContent(/^Success$/); + }); + + test("422 response results in display of error message", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 422, { + message: "422 error from mock", }); - const proxyCertificates = getByTestId("proxy_certificate_count"); - fireEvent.change(proxyCertificates, { - target: { value: expectedRequest.data.voters_counts.proxy_certificate_count.toString() }, + const user = userEvent.setup(); + + render(Component); + + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + const feedbackServerError = await screen.findByTestId("feedback-server-error"); + expect(feedbackServerError).toHaveTextContent(/^Error422 error from mock$/); + + expect(screen.queryByTestId("result")).not.toBeNull(); + expect(screen.queryByTestId("result")).toHaveTextContent(/^422 error from mock$/); + }); + + test("500 response results in display of error message", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 500, { + message: "500 error from mock", + errorCode: "500_ERROR", }); - const voterCards = getByTestId("voter_card_count"); - fireEvent.change(voterCards, { - target: { value: expectedRequest.data.voters_counts.voter_card_count.toString() }, + const user = userEvent.setup(); + + render(Component); + + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + const feedbackServerError = await screen.findByTestId("feedback-server-error"); + expect(feedbackServerError).toHaveTextContent(/^Error500 error from mock$/); + + expect(screen.queryByTestId("result")).not.toBeNull(); + expect(screen.queryByTestId("result")).toHaveTextContent(/^500 error from mock$/); + }); + }); + + describe("VotersAndVotesForm errors", () => { + test("F.01 Invalid value", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 422, { + error: + "Failed to deserialize the JSON body into the target type: data.voters_counts.poll_card_count: invalid value: integer `-3`, expected u32 at line 1 column 525", }); - const totalAdmittedVoters = getByTestId("total_admitted_voters_count"); - fireEvent.change(totalAdmittedVoters, { - target: { - value: expectedRequest.data.voters_counts.total_admitted_voters_count.toString(), + const user = userEvent.setup(); + + render(Component); + + // Since the component does not allow to input invalid values such as -3, + // not inputting any values and just clicking the submit button. + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + + const feedbackServerError = await screen.findByTestId("feedback-server-error"); + expect(feedbackServerError).toHaveTextContent(/^Error$/); + expect(screen.queryByTestId("feedback-warning")).toBeNull(); + expect(screen.queryByTestId("feedback-error")).toBeNull(); + }); + + test("F.11 IncorrectTotal Voters", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 200, { + saved: true, + message: "Data entry saved successfully", + validation_results: { + errors: [ + { + fields: [ + "data.voters_counts.total_admitted_voters_count", + "data.voters_counts.poll_card_count", + "data.voters_counts.proxy_certificate_count", + "data.voters_counts.voter_card_count", + ], + code: "IncorrectTotal", + }, + ], + warnings: [], }, }); - const votesOnCandidates = getByTestId("votes_candidates_counts"); - fireEvent.change(votesOnCandidates, { - target: { value: expectedRequest.data.votes_counts.votes_candidates_counts.toString() }, - }); + const user = userEvent.setup(); - const blankVotes = getByTestId("blank_votes_count"); - fireEvent.change(blankVotes, { - target: { value: expectedRequest.data.votes_counts.blank_votes_count.toString() }, - }); + render(Component); - const invalidVotes = getByTestId("invalid_votes_count"); - fireEvent.change(invalidVotes, { - target: { value: expectedRequest.data.votes_counts.invalid_votes_count.toString() }, - }); + await user.type(screen.getByTestId("poll_card_count"), "1"); + await user.type(screen.getByTestId("proxy_certificate_count"), "1"); + await user.type(screen.getByTestId("voter_card_count"), "1"); + await user.type(screen.getByTestId("total_admitted_voters_count"), "4"); + + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); - const totalVotesCast = getByTestId("total_votes_cast_count"); - fireEvent.change(totalVotesCast, { - target: { value: expectedRequest.data.votes_counts.total_votes_cast_count.toString() }, + const feedbackError = await screen.findByTestId("feedback-error"); + expect(feedbackError).toHaveTextContent(/^IncorrectTotal$/); + expect(screen.queryByTestId("feedback-warning")).toBeNull(); + expect(screen.queryByTestId("server-feedback-error")).toBeNull(); + }); + + test("F.12 IncorrectTotal Votes", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 200, { + saved: true, + message: "Data entry saved successfully", + validation_results: { + errors: [ + { + fields: [ + "data.votes_counts.total_votes_cast_count", + "data.votes_counts.votes_candidates_counts", + "data.votes_counts.blank_votes_count", + "data.votes_counts.invalid_votes_count", + ], + code: "IncorrectTotal", + }, + { + fields: ["data.votes_counts.votes_candidates_counts", "data.political_group_votes"], + code: "IncorrectTotal", + }, + ], + warnings: [], + }, }); + const user = userEvent.setup(); + + render(Component); + + await user.type(screen.getByTestId("votes_candidates_counts"), "1"); + await user.type(screen.getByTestId("blank_votes_count"), "1"); + await user.type(screen.getByTestId("invalid_votes_count"), "1"); + await user.type(screen.getByTestId("total_votes_cast_count"), "4"); + const submitButton = screen.getByRole("button", { name: "Volgende" }); await user.click(submitButton); - expect(spy).toHaveBeenCalledWith("http://testhost/v1/api/polling_stations/1/data_entries/1", { - method: "POST", - body: JSON.stringify(expectedRequest), - headers: { - "Content-Type": "application/json", + const feedbackError = await screen.findByTestId("feedback-error"); + expect(feedbackError).toHaveTextContent(/^IncorrectTotal$/); + expect(screen.queryByTestId("feedback-warning")).toBeNull(); + expect(screen.queryByTestId("server-feedback-error")).toBeNull(); + }); + + test("Error with non-existing fields is not displayed", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 200, { + saved: true, + message: "Data entry saved successfully", + validation_results: { + errors: [ + { + fields: [ + "data.not_a_real_object.not_a_real_field", + "data.not_a_real_object.this_field_does_not_exist", + ], + code: "NotARealError", + }, + ], + warnings: [], }, }); - const result = await screen.findByTestId("result"); - expect(result).toHaveTextContent(/^Success$/); + const user = userEvent.setup(); + + render(Component); + + // Since the component does not allow to input values for non-existing fields, + // not inputting any values and just clicking the submit button. + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + + expect(screen.queryByTestId("result")).toBeNull(); + expect(screen.queryByTestId("feedback-error")).toBeNull(); + expect(screen.queryByTestId("feedback-warning")).toBeNull(); + expect(screen.queryByTestId("feedback-server-error")).toBeNull(); }); }); - test("422 response results in display of error message", async () => { - overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 422, { - message: "422 error from mock", - }); + describe("VotersAndVotesForm warnings", () => { + test("W.21 AboveThreshold blank votes", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 200, { + saved: true, + message: "Data entry saved successfully", + validation_results: { + errors: [], + warnings: [ + { + fields: [ + "data.votes_counts.blank_votes_count", + "data.votes_counts.total_votes_cast_count", + ], + code: "AboveThreshold", + }, + ], + }, + }); - const user = userEvent.setup(); + const user = userEvent.setup(); - render(); + render(Component); - const submitButton = screen.getByRole("button", { name: "Volgende" }); - await user.click(submitButton); - const result = await screen.findByTestId("result"); - expect(result).toHaveTextContent(/^422 error from mock$/); - }); + await user.type(screen.getByTestId("votes_candidates_counts"), "0"); + await user.type(screen.getByTestId("blank_votes_count"), "1"); + await user.type(screen.getByTestId("invalid_votes_count"), "0"); + await user.type(screen.getByTestId("total_votes_cast_count"), "1"); + + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); - test("500 response results in display of error message", async () => { - overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 500, { - message: "500 error from mock", - errorCode: "500_ERROR", + const feedbackWarning = await screen.findByTestId("feedback-warning"); + expect(feedbackWarning).toHaveTextContent(/^AboveThreshold$/); + expect(screen.queryByTestId("feedback-server-error")).toBeNull(); + expect(screen.queryByTestId("feedback-error")).toBeNull(); }); - const user = userEvent.setup(); + test("W.22 AboveThreshold invalid votes", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 200, { + saved: true, + message: "Data entry saved successfully", + validation_results: { + errors: [], + warnings: [ + { + fields: [ + "data.votes_counts.blank_votes_count", + "data.votes_counts.total_votes_cast_count", + ], + code: "AboveThreshold", + }, + ], + }, + }); - render(); + const user = userEvent.setup(); - const submitButton = screen.getByRole("button", { name: "Volgende" }); - await user.click(submitButton); - const result = await screen.findByTestId("result"); - expect(result).toHaveTextContent(/^500 error from mock$/); - }); + render(Component); + + await user.type(screen.getByTestId("votes_candidates_counts"), "0"); + await user.type(screen.getByTestId("blank_votes_count"), "0"); + await user.type(screen.getByTestId("invalid_votes_count"), "1"); + await user.type(screen.getByTestId("total_votes_cast_count"), "1"); + + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); - test("Incorrect total is caught by validation", async () => { - const { getByTestId } = render(); + const feedbackWarning = await screen.findByTestId("feedback-warning"); + expect(feedbackWarning).toHaveTextContent(/^AboveThreshold$/); + expect(screen.queryByTestId("feedback-server-error")).toBeNull(); + expect(screen.queryByTestId("feedback-error")).toBeNull(); + }); - const setValue = (id: string, value: string | number) => { - const el = getByTestId(id); - fireEvent.change(el, { - target: { value: `${value}` }, + test("W.27 EqualInput voters and votes", async () => { + overrideOnce("post", "/v1/api/polling_stations/1/data_entries/1", 200, { + saved: true, + message: "Data entry saved successfully", + validation_results: { + errors: [ + { + fields: ["data.votes_counts.votes_candidates_counts", "data.political_group_votes"], + code: "IncorrectTotal", + }, + ], + warnings: [ + { + fields: ["data.voters_counts", "data.votes_counts"], + code: "EqualInput", + }, + ], + }, }); - }; - setValue("poll_card_count", 1); - setValue("proxy_certificate_count", 1); - setValue("voter_card_count", 1); - setValue("total_admitted_voters_count", 4); + const user = userEvent.setup(); + + render(Component); - setValue("votes_candidates_counts", 1); - setValue("blank_votes_count", 1); - setValue("invalid_votes_count", 1); - setValue("total_votes_cast_count", 4); + await user.type(screen.getByTestId("poll_card_count"), "1"); + await user.type(screen.getByTestId("proxy_certificate_count"), "0"); + await user.type(screen.getByTestId("voter_card_count"), "0"); + await user.type(screen.getByTestId("total_admitted_voters_count"), "1"); - const user = userEvent.setup(); - const submitButton = screen.getByRole("button", { name: "Volgende" }); - await user.click(submitButton); + await user.type(screen.getByTestId("votes_candidates_counts"), "1"); + await user.type(screen.getByTestId("blank_votes_count"), "0"); + await user.type(screen.getByTestId("invalid_votes_count"), "0"); + await user.type(screen.getByTestId("total_votes_cast_count"), "1"); - const result = await screen.findByTestId("error-codes"); - expect(result).toHaveTextContent(/^IncorrectTotal,IncorrectTotal$/); + const submitButton = screen.getByRole("button", { name: "Volgende" }); + await user.click(submitButton); + + const feedbackWarning = await screen.findByTestId("feedback-warning"); + expect(feedbackWarning).toHaveTextContent(/^EqualInput$/); + expect(screen.queryByTestId("feedback-server-error")).toBeNull(); + expect(screen.queryByTestId("feedback-error")).toBeNull(); + }); }); }); diff --git a/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.tsx b/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.tsx index 4bc172831..af221036b 100644 --- a/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.tsx +++ b/frontend/app/component/form/voters_and_votes/VotersAndVotesForm.tsx @@ -1,12 +1,9 @@ import * as React from "react"; -import { usePollingStationDataEntry, ValidationResult, ErrorsAndWarnings } from "@kiesraad/api"; +import { useVotersAndVotes, VotersAndVotesValues, useErrorsAndWarnings } from "@kiesraad/api"; import { Button, InputGrid, Feedback, BottomBar, InputGridRow, useTooltip } from "@kiesraad/ui"; -import { - usePositiveNumberInputMask, - usePreventFormEnterSubmit, - fieldNameFromPath, -} from "@kiesraad/util"; +import { usePositiveNumberInputMask, usePreventFormEnterSubmit } from "@kiesraad/util"; +import { useBlocker } from "react-router-dom"; interface FormElements extends HTMLFormControlsCollection { poll_card_count: HTMLInputElement; @@ -33,21 +30,25 @@ export function VotersAndVotesForm() { } = usePositiveNumberInputMask(); const formRef = React.useRef(null); usePreventFormEnterSubmit(formRef); - const [doSubmit, { data, loading, error }] = usePollingStationDataEntry({ - polling_station_id: 1, - entry_number: 1, - }); + + const { + sectionValues, + setSectionValues, + loading, + errors, + warnings, + serverError, + isCalled, + setTemporaryCache, + } = useVotersAndVotes(); useTooltip({ onDismiss: resetWarnings, }); - function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - const elements = event.currentTarget.elements; - - doSubmit({ - data: { + const getValues = React.useCallback( + (elements: VotersAndVotesFormElement["elements"]): VotersAndVotesValues => { + return { voters_counts: { poll_card_count: deformat(elements.poll_card_count.value), proxy_certificate_count: deformat(elements.proxy_certificate_count.value), @@ -60,95 +61,58 @@ export function VotersAndVotesForm() { invalid_votes_count: deformat(elements.invalid_votes_count.value), total_votes_cast_count: deformat(elements.total_votes_cast_count.value), }, - differences_counts: { - more_ballots_count: 0, - fewer_ballots_count: 0, - unreturned_ballots_count: 0, - too_few_ballots_handed_out_count: 0, - too_many_ballots_handed_out_count: 0, - other_explanation_count: 0, - no_explanation_count: 0, - }, - political_group_votes: [ - { - candidate_votes: [{ number: 1, votes: 0 }], - number: 1, - total: 0, - }, - ], - }, - }); - } - - const errorsAndWarnings: Map = React.useMemo(() => { - const result = new Map(); + }; + }, + [deformat], + ); - const process = (target: keyof ErrorsAndWarnings, arr: ValidationResult[]) => { - arr.forEach((v) => { - v.fields.forEach((f) => { - const fieldName = fieldNameFromPath(f); - if (!result.has(fieldName)) { - result.set(fieldName, { errors: [], warnings: [] }); - } - const field = result.get(fieldName); - if (field) { - field[target].push({ - code: v.code, - id: fieldName, - }); - } - }); + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + const elements = event.currentTarget.elements; + setSectionValues(getValues(elements)); + } + //const blocker = useBlocker() use const blocker to render confirmation UI. + useBlocker(() => { + if (formRef.current && !isCalled) { + const elements = formRef.current.elements as VotersAndVotesFormElement["elements"]; + const values = getValues(elements); + setTemporaryCache({ + key: "voters_and_votes", + data: values, }); - }; - - if (data && data.validation_results.errors.length > 0) { - process("errors", data.validation_results.errors); - } - if (data && data.validation_results.warnings.length > 0) { - process("warnings", data.validation_results.warnings); } + return false; + }); - inputMaskWarnings.forEach((warning) => { - if (!result.has(warning.id)) { - result.set(warning.id, { errors: [], warnings: [] }); - } - const field = result.get(warning.id); - if (field) { - field.warnings.push(warning); - } - }); - - return result; - }, [data, inputMaskWarnings]); + const errorsAndWarnings = useErrorsAndWarnings(errors, warnings, inputMaskWarnings); React.useEffect(() => { - if (data) { + if (isCalled) { window.scrollTo(0, 0); } - }, [data]); - - const hasValidationError = data && data.validation_results.errors.length > 0; - const hasValidationWarning = data && data.validation_results.warnings.length > 0; + }, [isCalled]); + const hasValidationError = errors.length > 0; + const hasValidationWarning = warnings.length > 0; + const success = isCalled && !hasValidationError && !hasValidationWarning && !loading; return ( -
- {data && data.validation_results.errors.map((r) => r.code).join(",")} -
+ {/* Temporary while not navigating through form sections */} + {success &&
Success
}

Toegelaten kiezers en uitgebrachte stemmen

- {error && ( + {serverError && ( -
+

Error

-

{error.message}

+

{serverError.message}

)} {hasValidationError && ( -
+
    - {data.validation_results.errors.map((error, n) => ( + {errors.map((error, n) => (
  • {error.code}
  • ))}
@@ -156,25 +120,17 @@ export function VotersAndVotesForm() { )} - {hasValidationWarning && ( + {hasValidationWarning && !hasValidationError && ( -
+
    - {data.validation_results.warnings.map((warning, n) => ( + {warnings.map((warning, n) => (
  • {warning.code}
  • ))}
)} - - {data && !hasValidationError && ( - -
-

Success

-
-
- )} Veld @@ -185,39 +141,43 @@ export function VotersAndVotesForm() { @@ -225,38 +185,42 @@ export function VotersAndVotesForm() { diff --git a/frontend/app/component/pollingstation/PollingStationProgress.tsx b/frontend/app/component/pollingstation/PollingStationProgress.tsx new file mode 100644 index 000000000..2f0e65918 --- /dev/null +++ b/frontend/app/component/pollingstation/PollingStationProgress.tsx @@ -0,0 +1,52 @@ +import { useElection } from "@kiesraad/api"; +import { ProgressList } from "@kiesraad/ui"; + +import { Link, useLocation, useParams } from "react-router-dom"; + +export function PollingStationProgress() { + const { pollingStationId, listNumber } = useParams(); + const { election } = useElection(); + const { pathname } = useLocation(); + + const targetForm = currentSectionFromPath(pathname); + + const lists = election.political_groups; + + return ( + + + Is er herteld? + + + + Aantal kiezers en stemmen + + + + Verschillen + + + {lists.map((list, index) => { + const listId = `${index + 1}`; + return ( + + {list.name} + + ); + })} + + + Controleren en opslaan + + + ); +} + +function currentSectionFromPath(pathname: string): string { + //4 deep; + const pathParts = pathname.split("/"); + if (pathParts.length >= 4) { + return pathParts[4] || ""; + } + return ""; +} diff --git a/frontend/app/module/input/PollingStation/CandidatesVotesPage.tsx b/frontend/app/module/input/PollingStation/CandidatesVotesPage.tsx index d4cd47b62..7cc467bd5 100644 --- a/frontend/app/module/input/PollingStation/CandidatesVotesPage.tsx +++ b/frontend/app/module/input/PollingStation/CandidatesVotesPage.tsx @@ -11,7 +11,7 @@ export function CandidatesVotesPage() { return
Geen lijstnummer gevonden
; } - const group = election.political_groups?.find( + const group = election.political_groups.find( (group) => group.number === parseInt(listNumber, 10), ); diff --git a/frontend/app/module/input/PollingStation/PollingStationLayout.test.tsx b/frontend/app/module/input/PollingStation/PollingStationLayout.test.tsx index ac9d262ed..15df11423 100644 --- a/frontend/app/module/input/PollingStation/PollingStationLayout.test.tsx +++ b/frontend/app/module/input/PollingStation/PollingStationLayout.test.tsx @@ -1,10 +1,17 @@ import { render } from "app/test/unit"; import { describe, expect, test } from "vitest"; +import { ElectionListProvider, ElectionProvider } from "@kiesraad/api"; import { PollingStationLayout } from "./PollingStationLayout"; describe("PollingStationLayout", () => { test("Enter form field values", () => { - render(); + render( + + + + + , + ); expect(true).toBe(true); }); }); diff --git a/frontend/app/module/input/PollingStation/PollingStationLayout.tsx b/frontend/app/module/input/PollingStation/PollingStationLayout.tsx index 3a146cdae..46634fb62 100644 --- a/frontend/app/module/input/PollingStation/PollingStationLayout.tsx +++ b/frontend/app/module/input/PollingStation/PollingStationLayout.tsx @@ -1,42 +1,26 @@ -import { useState, useEffect } from "react"; -import { Link, Outlet, useLocation, useParams } from "react-router-dom"; +import { useState } from "react"; +import { Outlet, useParams } from "react-router-dom"; -import { useElectionDataRequest } from "@kiesraad/api"; +import { PollingStationFormController, useElection } from "@kiesraad/api"; import { IconCross } from "@kiesraad/icon"; -import { - Badge, - Button, - Modal, - PollingStationNumber, - ProgressList, - WorkStationNumber, -} from "@kiesraad/ui"; +import { Badge, Button, Modal, PollingStationNumber, WorkStationNumber } from "@kiesraad/ui"; +import { PollingStationProgress } from "app/component/pollingstation/PollingStationProgress"; export function PollingStationLayout() { - const { electionId, pollingStationId, listNumber } = useParams(); + const { pollingStationId } = useParams(); + const { election } = useElection(); const [openModal, setOpenModal] = useState(false); - const { data } = useElectionDataRequest({ - election_id: parseInt(electionId || ""), - }); - const [lists, setLists] = useState([]); - const { pathname } = useLocation(); - - const targetForm = currentSectionFromPath(pathname); - - useEffect(() => { - if (data) { - const parties: string[] = []; - data.political_groups?.forEach((group: { name: string }) => parties.push(group.name)); - setLists(parties); - } - }, [data]); function changeDialog() { setOpenModal(!openModal); } return ( - <> +
{pollingStationId} @@ -52,44 +36,7 @@ export function PollingStationLayout() {
@@ -113,15 +60,6 @@ export function PollingStationLayout() { )} - + ); } - -function currentSectionFromPath(pathname: string): string { - //3 deep; - const pathParts = pathname.split("/"); - if (pathParts.length >= 4) { - return pathParts[4] || ""; - } - return ""; -} diff --git a/frontend/app/test/unit/Providers.tsx b/frontend/app/test/unit/Providers.tsx index cb2342736..b2b02223c 100644 --- a/frontend/app/test/unit/Providers.tsx +++ b/frontend/app/test/unit/Providers.tsx @@ -1,11 +1,12 @@ import * as React from "react"; -import { BrowserRouter } from "react-router-dom"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; import { ApiProvider } from "@kiesraad/api"; export const Providers = ({ children }: { children: React.ReactNode }) => { + const router = createMemoryRouter([{ path: "*", element: children }]); return ( - - {children} - + + + ); }; diff --git a/frontend/app/test/unit/test-utils.ts b/frontend/app/test/unit/test-utils.ts index 89470cd4d..4079a4f65 100644 --- a/frontend/app/test/unit/test-utils.ts +++ b/frontend/app/test/unit/test-utils.ts @@ -8,3 +8,29 @@ const customRender = (ui: ReactElement, options?: Omit export * from "@testing-library/react"; export { customRender as render }; + +export function getUrlMethodAndBody( + call: [input: string | URL | Request, init?: RequestInit | undefined][], +) { + let url; + let method; + let body; + + if (call.length > 0) { + if (call[0] && call[0].length > 1) { + if (call[0][0]) { + url = call[0][0]; + } + + if (call[0][1]) { + if (call[0][1].method) { + method = call[0][1].method; + } + if (call[0][1].body) { + body = JSON.parse(call[0][1].body as string) as object; + } + } + } + } + return { url, method, body }; +} diff --git a/frontend/lib/api-mocks/index.ts b/frontend/lib/api-mocks/index.ts index 12e46b7b9..338c3222b 100644 --- a/frontend/lib/api-mocks/index.ts +++ b/frontend/lib/api-mocks/index.ts @@ -6,8 +6,13 @@ import { POLLING_STATION_DATA_ENTRY_REQUEST_PARAMS, VotesCounts, VotersCounts, + Election, + PoliticalGroup, } from "@kiesraad/api"; -import { electionMockData, electionsMockData } from "./ElectionMockData.ts"; +import { electionMockData, electionsMockData, politicalGroupMockData } from "./ElectionMockData.ts"; + +export const electionMock = electionMockData as Required; +export const politicalGroupMock = politicalGroupMockData as Required; type ParamsToString = { [P in keyof T]: string; @@ -52,7 +57,7 @@ export const pollingStationDataEntryHandler = http.post< }, }; - const { voters_counts, votes_counts } = json.data; + const { voters_counts, votes_counts, political_group_votes } = json.data; const votesFields: (keyof VotesCounts)[] = [ "blank_votes_count", @@ -66,7 +71,7 @@ export const pollingStationDataEntryHandler = http.post< if (valueOutOfRange(votes_counts[field])) { response.validation_results.errors.push({ code: "OutOfRange", - fields: [field], + fields: [`data.votes_counts.${field}`], }); } } @@ -83,7 +88,7 @@ export const pollingStationDataEntryHandler = http.post< if (valueOutOfRange(voters_counts[field])) { response.validation_results.errors.push({ code: "OutOfRange", - fields: [field], + fields: [`data.voters_counts.${field}`], }); } }); @@ -97,10 +102,10 @@ export const pollingStationDataEntryHandler = http.post< ) { response.validation_results.errors.push({ fields: [ - "voters_counts.poll_card_count", - "voters_counts.proxy_certificate_count", - "voters_counts.voter_card_count", - "voters_counts.total_admitted_voters_count", + "data.voters_counts.poll_card_count", + "data.voters_counts.proxy_certificate_count", + "data.voters_counts.voter_card_count", + "data.voters_counts.total_admitted_voters_count", ], code: "IncorrectTotal", }); @@ -115,15 +120,44 @@ export const pollingStationDataEntryHandler = http.post< ) { response.validation_results.errors.push({ fields: [ - "votes_counts.total_votes_cast_count", - "votes_counts.votes_candidates_counts", - "votes_counts.blank_votes_count", - "votes_counts.invalid_votes_count", + "data.votes_counts.total_votes_cast_count", + "data.votes_counts.votes_candidates_counts", + "data.votes_counts.blank_votes_count", + "data.votes_counts.invalid_votes_count", ], code: "IncorrectTotal", }); } + //SECTION political_group_votes + political_group_votes.forEach((pg) => { + if (valueOutOfRange(pg.total)) { + response.validation_results.errors.push({ + code: "OutOfRange", + fields: [`data.political_group_votes[${pg.number - 1}].total`], + }); + } + + pg.candidate_votes.forEach((cv) => { + if (valueOutOfRange(cv.votes)) { + response.validation_results.errors.push({ + code: "OutOfRange", + fields: [ + `data.political_group_votes[${pg.number - 1}].candidate_votes[${cv.number - 1}].votes`, + ], + }); + } + }); + + const sum = pg.candidate_votes.reduce((acc, cv) => acc + cv.votes, 0); + if (sum !== pg.total) { + response.validation_results.errors.push({ + code: "IncorrectTotal", + fields: [`data.political_group_votes[${pg.number - 1}].total`], + }); + } + }); + //OPTION: threshold checks return HttpResponse.json(response, { status: 200 }); @@ -144,7 +178,7 @@ export const ElectionListRequestHandler = http.get("/v1/api/elections", () => { export const ElectionRequestHandler = http.get>( "/v1/api/elections/:id", () => { - return HttpResponse.json(electionMockData, { status: 200 }); + return HttpResponse.json({ election: electionMockData }, { status: 200 }); }, ); diff --git a/frontend/lib/api/election/ElectionProvider.tsx b/frontend/lib/api/election/ElectionProvider.tsx index 680ad3dcd..d46543f56 100644 --- a/frontend/lib/api/election/ElectionProvider.tsx +++ b/frontend/lib/api/election/ElectionProvider.tsx @@ -4,7 +4,7 @@ import { useElectionDataRequest } from "../useElectionDataRequest"; import { useElectionList } from "./useElectionList"; export interface iElectionProviderContext { - election: Election; + election: Required; } export const ElectionProviderContext = React.createContext( @@ -31,7 +31,7 @@ export function ElectionProvider({ children }: ElectionProviderProps) { } return ( - + }}> {children} ); diff --git a/frontend/lib/api/form/index.ts b/frontend/lib/api/form/index.ts new file mode 100644 index 000000000..1c6b20e00 --- /dev/null +++ b/frontend/lib/api/form/index.ts @@ -0,0 +1,2 @@ +export * from "./pollingstation"; +export * from "./useErrorsAndWarnings"; diff --git a/frontend/lib/api/form/pollingstation/PollingStationFormController.tsx b/frontend/lib/api/form/pollingstation/PollingStationFormController.tsx new file mode 100644 index 000000000..220f94d8d --- /dev/null +++ b/frontend/lib/api/form/pollingstation/PollingStationFormController.tsx @@ -0,0 +1,145 @@ +import * as React from "react"; +import { + ApiResponseErrorData, + DataEntryResponse, + Election, + PollingStationResults, + usePollingStationDataEntry, +} from "@kiesraad/api"; + +export interface PollingStationFormControllerProps { + election: Required; + pollingStationId: number; + entryNumber: number; + children: React.ReactNode; +} + +export interface iPollingStationControllerContext { + loading: boolean; + error: ApiResponseErrorData | null; + data: DataEntryResponse | null; + values: PollingStationResults; + setValues: React.Dispatch>; + setTemporaryCache: (cache: AnyCache | null) => boolean; + cache: AnyCache | null; +} + +//store unvalidated data +export type TemporaryCache = { + key: string; + data: T; + id?: number; +}; + +export interface TemporaryCacheVotersAndVotes + extends TemporaryCache> { + key: "voters_and_votes"; +} + +export interface TemporaryCachePoliticalGroupVotes + extends TemporaryCache { + key: "political_group_votes"; +} + +export interface TemporaryCacheDifferences + extends TemporaryCache { + key: "differences"; +} + +export type AnyCache = + | TemporaryCacheVotersAndVotes + | TemporaryCachePoliticalGroupVotes + | TemporaryCacheDifferences; + +export const PollingStationControllerContext = React.createContext< + iPollingStationControllerContext | undefined +>(undefined); + +export function PollingStationFormController({ + election, + pollingStationId, + entryNumber, + children, +}: PollingStationFormControllerProps) { + const [doRequest, { data, loading, error }] = usePollingStationDataEntry({ + polling_station_id: pollingStationId, + entry_number: entryNumber, + }); + + const temporaryCache = React.useRef(null); + + const [values, _setValues] = React.useState(() => ({ + political_group_votes: election.political_groups.map((pg) => ({ + number: pg.number, + total: 0, + candidate_votes: pg.candidates.map((c) => ({ + number: c.number, + votes: 0, + })), + })), + differences_counts: { + more_ballots_count: 0, + fewer_ballots_count: 0, + unreturned_ballots_count: 0, + too_few_ballots_handed_out_count: 0, + too_many_ballots_handed_out_count: 0, + other_explanation_count: 0, + no_explanation_count: 0, + }, + voters_counts: { + proxy_certificate_count: 0, + total_admitted_voters_count: 0, + voter_card_count: 0, + poll_card_count: 0, + }, + votes_counts: { + blank_votes_count: 0, + invalid_votes_count: 0, + total_votes_cast_count: 0, + votes_candidates_counts: 0, + }, + })); + + const _isCalled = React.useRef(false); + + const setValues = React.useCallback((values: React.SetStateAction) => { + _isCalled.current = true; + _setValues((old) => { + const newValues = typeof values === "function" ? values(old) : values; + return { + ...old, + ...newValues, + }; + }); + }, []); + + const setTemporaryCache = React.useCallback((cache: AnyCache | null) => { + //OPTIONAL: allow only cache for unvalidated data + temporaryCache.current = cache; + return true; + }, []); + + React.useEffect(() => { + if (_isCalled.current) { + doRequest({ + data: values, + }); + } + }, [doRequest, values]); + + return ( + + {children} + + ); +} diff --git a/frontend/lib/api/form/pollingstation/index.ts b/frontend/lib/api/form/pollingstation/index.ts new file mode 100644 index 000000000..aeda9431b --- /dev/null +++ b/frontend/lib/api/form/pollingstation/index.ts @@ -0,0 +1,5 @@ +export * from "./PollingStationFormController"; +export * from "./useDifferences"; +export * from "./usePoliticalGroups"; +export * from "./usePollingStationFormController"; +export * from "./useVotersAndVotes"; diff --git a/frontend/lib/api/form/pollingstation/useDifferences.ts b/frontend/lib/api/form/pollingstation/useDifferences.ts new file mode 100644 index 000000000..d54faefe9 --- /dev/null +++ b/frontend/lib/api/form/pollingstation/useDifferences.ts @@ -0,0 +1,72 @@ +import * as React from "react"; + +import { + DifferencesCounts, + usePollingStationFormController, + ValidationResult, +} from "@kiesraad/api"; +import { matchValidationResultWithFormSections } from "@kiesraad/util"; + +export function useDifferences() { + const { + values, + setValues, + data, + loading, + error: serverError, + setTemporaryCache, + cache, + } = usePollingStationFormController(); + + const sectionValues = React.useMemo(() => { + if (cache && cache.key === "differences") { + const data = cache.data; + setTemporaryCache(null); + return data; + } + return values.differences_counts; + }, [values, setTemporaryCache, cache]); + + const errors = React.useMemo(() => { + if (data) { + return data.validation_results.errors.filter((err) => + matchValidationResultWithFormSections(err.fields, ["differences_votes"]), + ); + } + return [] as ValidationResult[]; + }, [data]); + + const warnings = React.useMemo(() => { + if (data) { + return data.validation_results.warnings.filter((warning) => + matchValidationResultWithFormSections(warning.fields, ["differences_votes"]), + ); + } + return [] as ValidationResult[]; + }, [data]); + + const setSectionValues = (values: DifferencesCounts) => { + setValues((old) => ({ + ...old, + differences_counts: { + ...values, + }, + })); + }; + + const isCalled = React.useMemo(() => { + // TODO: How to know if this is called, all values can be 0? + return !!sectionValues.more_ballots_count; + }, [sectionValues]); + + return { + loading, + sectionValues, + setSectionValues, + errors, + warnings, + isCalled, + serverError, + setTemporaryCache, + }; +} diff --git a/frontend/lib/api/form/pollingstation/usePoliticalGroups.ts b/frontend/lib/api/form/pollingstation/usePoliticalGroups.ts new file mode 100644 index 000000000..e115dee91 --- /dev/null +++ b/frontend/lib/api/form/pollingstation/usePoliticalGroups.ts @@ -0,0 +1,78 @@ +import * as React from "react"; + +import { + PoliticalGroupVotes, + usePollingStationFormController, + ValidationResult, +} from "@kiesraad/api"; +import { matchValidationResultWithFormSections } from "@kiesraad/util"; + +export function usePoliticalGroup(political_group_number: number) { + const { + values, + setValues, + data, + loading, + error: serverError, + setTemporaryCache, + cache, + } = usePollingStationFormController(); + + const sectionValues = React.useMemo(() => { + if (cache && cache.key === "political_group_votes" && cache.id === political_group_number) { + const data = cache.data; + setTemporaryCache(null); + return data; + } + return values.political_group_votes.find((pg) => pg.number === political_group_number); + }, [values, political_group_number, setTemporaryCache, cache]); + + const errors = React.useMemo(() => { + if (data) { + return data.validation_results.errors.filter((err) => + matchValidationResultWithFormSections(err.fields, [ + `political_group_votes[${political_group_number - 1}]`, + ]), + ); + } + return [] as ValidationResult[]; + }, [data, political_group_number]); + + const warnings = React.useMemo(() => { + if (data) { + return data.validation_results.warnings.filter((warning) => + matchValidationResultWithFormSections(warning.fields, [ + `political_group_votes[${political_group_number - 1}]`, + ]), + ); + } + return [] as ValidationResult[]; + }, [data, political_group_number]); + + const setSectionValues = (values: PoliticalGroupVotes) => { + setValues((old) => ({ + ...old, + political_group_votes: old.political_group_votes.map((pg) => { + if (pg.number === political_group_number) { + return values; + } + return pg; + }), + })); + }; + + const isCalled = React.useMemo(() => { + return !!(sectionValues && sectionValues.total); + }, [sectionValues]); + + return { + sectionValues, + setSectionValues, + errors, + warnings, + loading, + isCalled, + serverError, + setTemporaryCache, + }; +} diff --git a/frontend/lib/api/form/pollingstation/usePollingStationFormController.ts b/frontend/lib/api/form/pollingstation/usePollingStationFormController.ts new file mode 100644 index 000000000..c4fea382c --- /dev/null +++ b/frontend/lib/api/form/pollingstation/usePollingStationFormController.ts @@ -0,0 +1,12 @@ +import * as React from "react"; +import { PollingStationControllerContext } from "./PollingStationFormController"; + +export function usePollingStationFormController() { + const context = React.useContext(PollingStationControllerContext); + if (!context) { + throw new Error( + "usePollingStationFormController must be used within a PollingStationFormController", + ); + } + return context; +} diff --git a/frontend/lib/api/form/pollingstation/useVotersAndVotes.ts b/frontend/lib/api/form/pollingstation/useVotersAndVotes.ts new file mode 100644 index 000000000..c6d6fd7e6 --- /dev/null +++ b/frontend/lib/api/form/pollingstation/useVotersAndVotes.ts @@ -0,0 +1,72 @@ +import * as React from "react"; + +import { + PollingStationResults, + usePollingStationFormController, + ValidationResult, +} from "@kiesraad/api"; +import { matchValidationResultWithFormSections } from "@kiesraad/util"; + +export type VotersAndVotesValues = Pick; + +export function useVotersAndVotes() { + const { values, setValues, loading, error, data, setTemporaryCache, cache } = + usePollingStationFormController(); + + const sectionValues = React.useMemo(() => { + if (cache && cache.key === "voters_and_votes") { + const data = cache.data as VotersAndVotesValues; + setTemporaryCache(null); + return data; + } + return { + voters_counts: values.voters_counts, + votes_counts: values.votes_counts, + }; + }, [values, setTemporaryCache, cache]); + + const errors = React.useMemo(() => { + if (data) { + return data.validation_results.errors.filter((err) => + matchValidationResultWithFormSections(err.fields, ["voters_counts", "votes_counts"]), + ); + } + return [] as ValidationResult[]; + }, [data]); + + const warnings = React.useMemo(() => { + if (data) { + return data.validation_results.warnings.filter((warning) => + matchValidationResultWithFormSections(warning.fields, ["voters_counts", "votes_counts"]), + ); + } + return [] as ValidationResult[]; + }, [data]); + + const setSectionValues = (values: VotersAndVotesValues) => { + setValues((old) => ({ + ...old, + voters_counts: { + ...values.voters_counts, + }, + votes_counts: { + ...values.votes_counts, + }, + })); + }; + + const isCalled = React.useMemo(() => { + return sectionValues.votes_counts.total_votes_cast_count > 0; + }, [sectionValues]); + + return { + loading, + sectionValues, + setSectionValues, + errors, + warnings, + serverError: error, + isCalled, + setTemporaryCache, + }; +} diff --git a/frontend/lib/api/form/useErrorsAndWarnings.ts b/frontend/lib/api/form/useErrorsAndWarnings.ts new file mode 100644 index 000000000..8812cc9af --- /dev/null +++ b/frontend/lib/api/form/useErrorsAndWarnings.ts @@ -0,0 +1,53 @@ +import * as React from "react"; +import { ErrorsAndWarnings, FieldValidationResult } from "../api"; +import { ValidationResult } from "../gen/openapi"; +import { fieldNameFromPath } from "@kiesraad/util"; + +export function useErrorsAndWarnings( + errors: ValidationResult[], + warnings: ValidationResult[], + clientWarnings: FieldValidationResult[], +) { + const errorsAndWarnings: Map = React.useMemo(() => { + const result = new Map(); + + const process = (target: keyof ErrorsAndWarnings, arr: ValidationResult[]) => { + arr.forEach((v) => { + v.fields.forEach((f) => { + const fieldName = fieldNameFromPath(f); + if (!result.has(fieldName)) { + result.set(fieldName, { errors: [], warnings: [] }); + } + const field = result.get(fieldName); + if (field) { + field[target].push({ + code: v.code, + id: fieldName, + }); + } + }); + }); + }; + + if (errors.length > 0) { + process("errors", errors); + } + if (warnings.length > 0) { + process("warnings", warnings); + } + + clientWarnings.forEach((warning) => { + if (!result.has(warning.id)) { + result.set(warning.id, { errors: [], warnings: [] }); + } + const field = result.get(warning.id); + if (field) { + field.warnings.push(warning); + } + }); + + return result; + }, [errors, warnings, clientWarnings]); + + return errorsAndWarnings; +} diff --git a/frontend/lib/api/index.ts b/frontend/lib/api/index.ts index 42440dd70..34d8bc407 100644 --- a/frontend/lib/api/index.ts +++ b/frontend/lib/api/index.ts @@ -8,3 +8,4 @@ export * from "./useApiPostRequest"; export * from "./useElectionDataRequest"; export * from "./useElectionListRequest"; export * from "./usePollingStationDataEntry"; +export * from "./form"; diff --git a/frontend/lib/api/useElectionDataRequest.ts b/frontend/lib/api/useElectionDataRequest.ts index b64f091cd..57be94e79 100644 --- a/frontend/lib/api/useElectionDataRequest.ts +++ b/frontend/lib/api/useElectionDataRequest.ts @@ -13,5 +13,5 @@ export function useElectionDataRequest(params: ELECTION_DETAILS_REQUEST_PARAMS) path = ""; } - return useApiGetRequest(path); + return useApiGetRequest<{ election: Election }>(path); } diff --git a/frontend/lib/ui/InputGrid/InputGrid.tsx b/frontend/lib/ui/InputGrid/InputGrid.tsx index e12882a65..93eda0590 100644 --- a/frontend/lib/ui/InputGrid/InputGrid.tsx +++ b/frontend/lib/ui/InputGrid/InputGrid.tsx @@ -142,14 +142,18 @@ InputGrid.Row = ({ isTotal, isFocused, addSeparator, + id, }: { children: [React.ReactElement, React.ReactElement, React.ReactElement]; isTotal?: boolean; isFocused?: boolean; addSeparator?: boolean; + id?: string; }) => ( <> - {children} + + {children} + {addSeparator && } ); diff --git a/frontend/lib/ui/InputGrid/InputGridRow.tsx b/frontend/lib/ui/InputGrid/InputGridRow.tsx index 07b9b0f22..ceee36f14 100644 --- a/frontend/lib/ui/InputGrid/InputGridRow.tsx +++ b/frontend/lib/ui/InputGrid/InputGridRow.tsx @@ -9,16 +9,18 @@ import { Icon } from "../Icon/Icon"; import { InputGrid } from "./InputGrid"; export interface InputGridRowProps { + id: string; field: string; - name: string; title: string; errorsAndWarnings: Map; inputProps: Partial>; format: FormatFunc; - defaultValue?: string; + name?: string; + defaultValue?: string | number; isTotal?: boolean; isFocused?: boolean; addSeparator?: boolean; + autoFocus?: boolean; } export function InputGridRow({ @@ -30,20 +32,21 @@ export function InputGridRow({ defaultValue, inputProps, isTotal, + id, isFocused = false, addSeparator, }: InputGridRowProps) { - const errors = errorsAndWarnings.get(name)?.errors; + const errors = errorsAndWarnings.get(id)?.errors; const warnings = errorsAndWarnings - .get(name) + .get(id) ?.warnings.filter((warning) => warning.code !== "REFORMAT_WARNING"); const tooltip = errorsAndWarnings - .get(name) + .get(id) ?.warnings.find((warning) => warning.code === "REFORMAT_WARNING")?.value; - const [value, setValue] = React.useState(defaultValue || ""); + const [value, setValue] = React.useState(() => (defaultValue ? format(defaultValue) : "")); return ( - + {field} { expect(fieldNameFromPath(input)).equals(expected); }); + +test("matchValidationResultWithFormSections", () => { + let fields = [ + "data.votes_counts.test1", + "data.votes_counts.test2", + "data.voters_counts.test3", + "data.voters_counts.test4", + ]; + let sections = ["votes_counts", "voters_counts"]; + expect(matchValidationResultWithFormSections(fields, sections)).equals(true); + + fields = ["data.test1.val1", "data.test2.val2"]; + sections = ["test1"]; + + expect(matchValidationResultWithFormSections(fields, sections)).equals(false); + + fields = ["data.political_group_votes[1].total"]; + sections = ["political_group_votes[1]"]; + + expect(matchValidationResultWithFormSections(fields, sections)).equals(true); +}); diff --git a/frontend/lib/util/fields.ts b/frontend/lib/util/fields.ts index 341d923f0..44e4688d5 100644 --- a/frontend/lib/util/fields.ts +++ b/frontend/lib/util/fields.ts @@ -1,4 +1,46 @@ export function fieldNameFromPath(path: string): string { + let result = ""; const bits = path.split("."); - return bits[bits.length - 1] || path; + // flatten path at depth 4: data.political_group_votes[1].candidate_votes[1].votes + if (bits.length === 4) { + const [, , subsection, field] = bits; + // replace [1] with -1 + const b = subsection?.replace(/\[(\d+)\]/, "-$1"); + return `${b}.${field}`; + } else { + result = bits[bits.length - 1] || ""; + } + return result || path; +} + +/** + * + * @param fields Fields in Validation result data.. + * @param formSections keys in response data to match + * @returns true if fields match formSections + */ +export function matchValidationResultWithFormSections( + fields: string[], + formSections: string[], +): boolean { + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + if (field) { + const bits = field.split("."); + let result = false; + for (let j = 0; j < formSections.length; j++) { + const section = formSections[j]; + if (section) { + if (bits.includes(section)) { + result = true; + break; + } + } + } + if (!result) { + return false; + } + } + } + return true; }