Skip to content

Commit

Permalink
Add update user form and page (#1086)
Browse files Browse the repository at this point in the history
  • Loading branch information
oliver3 authored Feb 26, 2025
1 parent 9e93188 commit aa2f385
Show file tree
Hide file tree
Showing 15 changed files with 418 additions and 9 deletions.
8 changes: 8 additions & 0 deletions frontend/app/module/users/UserListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { formatDateTime, useQueryParam } from "@kiesraad/util";
export function UserListPage() {
const { requestState } = useUserListRequest();
const [createdMessage, clearCreatedMessage] = useQueryParam("created");
const [updatedMessage, clearUpdatedMessage] = useQueryParam("updated");

if (requestState.status === "loading") {
return <Loader />;
Expand Down Expand Up @@ -45,6 +46,13 @@ export function UserListPage() {
</Alert>
)}

{updatedMessage && (
<Alert type="success" onClose={clearUpdatedMessage}>
<h2>{t("users.user_updated")}</h2>
<p>{updatedMessage}</p>
</Alert>
)}

<main>
<article>
<Toolbar>
Expand Down
10 changes: 4 additions & 6 deletions frontend/app/module/users/create/UserCreateDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@ import { CreateUserRequest, isSuccess, Role } from "@kiesraad/api";
import { t } from "@kiesraad/i18n";
import { Alert, Button, Form, FormLayout, InputField, PageTitle } from "@kiesraad/ui";

import { MIN_PASSWORD_LENGTH, validatePassword } from "../validatePassword";
import { useUserCreateContext } from "./useUserCreateContext";

type ValidationErrors = Partial<CreateUserRequest>;

const MIN_PASSWORD_LENGTH = 12;

export function UserCreateDetailsPage() {
const navigate = useNavigate();
const { role, type, username, createUser, apiError, saving } = useUserCreateContext();
Expand Down Expand Up @@ -59,10 +58,9 @@ export function UserCreateDetailsPage() {
errors.fullname = required;
}

if (user.temp_password.length === 0) {
errors.temp_password = required;
} else if (user.temp_password.length < MIN_PASSWORD_LENGTH) {
errors.temp_password = t("users.temporary_password_error_min_length", { min_length: MIN_PASSWORD_LENGTH });
const passwordError = validatePassword(user.temp_password);
if (passwordError) {
errors.temp_password = passwordError;
}

const isValid = Object.keys(errors).length === 0;
Expand Down
1 change: 1 addition & 0 deletions frontend/app/module/users/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./UserListPage";
export * from "./create";
export * from "./update";
107 changes: 107 additions & 0 deletions frontend/app/module/users/update/UserUpdateForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { describe, expect, test, vi } from "vitest";

import { User } from "@kiesraad/api";
import { render } from "@kiesraad/test";

import { UserUpdateForm } from "./UserUpdateForm";

async function renderForm(user: Partial<User> = {}, saving = false) {
const onSave = vi.fn();
const onAbort = vi.fn();

render(
<UserUpdateForm
user={{ role: "typist", username: "Gebruiker01", ...user } as User}
onSave={onSave}
onAbort={onAbort}
saving={saving}
></UserUpdateForm>,
);

expect(await screen.findByRole("heading", { name: "Details van het account" })).toBeInTheDocument();
return { onSave, onAbort };
}

describe("UserUpdateForm", () => {
test("renders username and role", async () => {
await renderForm();
expect(await screen.findByText("Gebruiker01")).toBeInTheDocument();
expect(await screen.findByText("Invoerder")).toBeInTheDocument();
});

test("fullname field", async () => {
const { onSave } = await renderForm({ fullname: "Voor en Achternaam" });
const fullnameInput = screen.getByLabelText("Volledige naam");
expect(fullnameInput).toBeInTheDocument();
expect(fullnameInput).toHaveValue("Voor en Achternaam");

const user = userEvent.setup();
await user.clear(fullnameInput);
await user.click(screen.getByRole("button", { name: "Wijzigingen opslaan" }));

expect(onSave).not.toHaveBeenCalled();
expect(fullnameInput).toBeInvalid();
expect(fullnameInput).toHaveAccessibleErrorMessage("Dit veld mag niet leeg zijn");

await user.type(fullnameInput, "Nieuwe Naam");
await user.click(screen.getByRole("button", { name: "Wijzigingen opslaan" }));

expect(onSave).toHaveBeenCalledExactlyOnceWith({
fullname: "Nieuwe Naam",
});
});

test("without fullname", async () => {
const { onSave } = await renderForm({ fullname: undefined });
expect(screen.queryByLabelText("Volledige naam")).not.toBeInTheDocument();

const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Wijzigingen opslaan" }));

expect(onSave).toHaveBeenCalledExactlyOnceWith({});
});

test("password field", async () => {
const { onSave } = await renderForm();

const user = userEvent.setup();
expect(screen.queryByLabelText("Nieuw wachtwoord")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Wijzig wachtwoord" }));

const passwordInput = await screen.findByLabelText("Nieuw wachtwoord");
expect(passwordInput).toBeInTheDocument();
expect(passwordInput).toHaveValue("");

expect(screen.queryByRole("button", { name: "Wijzig wachtwoord" })).not.toBeInTheDocument();

const save = screen.getByRole("button", { name: "Wijzigingen opslaan" });
await user.click(save);

expect(passwordInput).toBeInvalid();
expect(passwordInput).toHaveAccessibleErrorMessage("Dit veld mag niet leeg zijn");

await user.type(passwordInput, "Vol");
await user.click(save);
expect(passwordInput).toHaveAccessibleErrorMessage(
"Dit wachtwoord is niet lang genoeg. Gebruik minimaal 12 karakters",
);

await user.type(passwordInput, "doendeKarakters01");
await user.click(save);

expect(passwordInput).not.toBeInvalid();
expect(onSave).toHaveBeenCalledExactlyOnceWith({
temp_password: "VoldoendeKarakters01",
});
});

test("abort update", async () => {
const { onAbort, onSave } = await renderForm();
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Annuleren" }));
expect(onAbort).toHaveBeenCalledOnce();
expect(onSave).not.toHaveBeenCalled();
});
});
117 changes: 117 additions & 0 deletions frontend/app/module/users/update/UserUpdateForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { FormEvent, useState } from "react";

import { UpdateUserRequest, User } from "@kiesraad/api";
import { t } from "@kiesraad/i18n";
import { IconPencil } from "@kiesraad/icon";
import { Button, Form, FormLayout, InputField } from "@kiesraad/ui";

import { MIN_PASSWORD_LENGTH, validatePassword } from "../validatePassword";

export interface UserUpdateFormProps {
user: User;
onSave: (userUpdate: UpdateUserRequest) => void;
onAbort: () => void;
saving: boolean;
}

type ValidationErrors = Partial<UpdateUserRequest>;

export function UserUpdateForm({ user, onSave, onAbort, saving }: UserUpdateFormProps) {
const [editPassword, setEditPassword] = useState(false);
const [validationErrors, setValidationErrors] = useState<ValidationErrors>();

function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);

const userUpdate: UpdateUserRequest = {};
const errors: ValidationErrors = {};

if (user.fullname) {
userUpdate.fullname = (formData.get("fullname") as string).trim();
if (userUpdate.fullname.length === 0) {
errors.fullname = t("form_errors.FORM_VALIDATION_RESULT_REQUIRED");
}
}

if (editPassword) {
userUpdate.temp_password = formData.get("temp_password") as string;
const passwordError = validatePassword(userUpdate.temp_password);
if (passwordError) {
errors.temp_password = passwordError;
}
}

const isValid = Object.keys(errors).length === 0;
setValidationErrors(isValid ? undefined : errors);

if (isValid) {
onSave(userUpdate);
}
}

return (
<>
<Form onSubmit={handleSubmit}>
<FormLayout width="medium" disabled={saving}>
<FormLayout.Section title={t("users.details_title")}>
<InputField
id="username"
name="username"
disabled={true}
value={user.username}
label={t("users.username")}
hint={t("users.username_hint_disabled")}
/>

{user.fullname && (
<InputField
id="fullname"
name="fullname"
defaultValue={user.fullname}
label={t("users.fullname")}
hint={t("users.fullname_hint")}
error={validationErrors?.fullname}
/>
)}

{editPassword ? (
<InputField
id="temp_password"
name="temp_password"
label={t("users.new_password")}
hint={t("users.temporary_password_hint", { min_length: MIN_PASSWORD_LENGTH })}
error={validationErrors?.temp_password}
/>
) : (
<FormLayout.Field label={t("user.password")}>
{t("users.change_password_hint")}

<Button
type="button"
variant="secondary"
size="md"
onClick={() => {
setEditPassword(true);
}}
>
<IconPencil />
{t("users.change_password")}
</Button>
</FormLayout.Field>
)}

<FormLayout.Field label={t("role")}>{t(user.role)}</FormLayout.Field>

<FormLayout.Controls>
<Button type="submit">{t("save_changes")}</Button>
<Button type="button" variant="secondary" onClick={onAbort}>
{t("cancel")}
</Button>
</FormLayout.Controls>
</FormLayout.Section>
</FormLayout>
</Form>
</>
);
}
37 changes: 37 additions & 0 deletions frontend/app/module/users/update/UserUpdatePage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { beforeEach, describe, expect, test, vi } from "vitest";

import { UserGetRequestHandler, UserUpdateRequestHandler } from "@kiesraad/api-mocks";
import { render, server } from "@kiesraad/test";

import { UserUpdatePage } from "./UserUpdatePage";

const navigate = vi.fn();

vi.mock(import("@kiesraad/util"), async (importOriginal) => ({
...(await importOriginal()),
useNumericParam: () => 1,
}));

vi.mock(import("react-router"), async (importOriginal) => ({
...(await importOriginal()),
useNavigate: () => navigate,
}));

describe("UserUpdatePage", () => {
beforeEach(() => {
server.use(UserGetRequestHandler, UserUpdateRequestHandler);
});

test("update user", async () => {
render(<UserUpdatePage></UserUpdatePage>);
expect(await screen.findByRole("heading", { name: "Details van het account" })).toBeInTheDocument();

const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Wijzigingen opslaan" }));

const expectedMessage = "De wijzigingen in het account van Sanne zijn opgeslagen";
expect(navigate).toHaveBeenCalledExactlyOnceWith(`/users?updated=${encodeURIComponent(expectedMessage)}`);
});
});
58 changes: 58 additions & 0 deletions frontend/app/module/users/update/UserUpdatePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useNavigate } from "react-router";

import { UpdateUserRequest, useApiRequest, User, USER_GET_REQUEST_PATH } from "@kiesraad/api";
import { t } from "@kiesraad/i18n";
import { Alert, FormLayout, Loader, PageTitle } from "@kiesraad/ui";
import { useNumericParam } from "@kiesraad/util";

import { UserUpdateForm } from "./UserUpdateForm";
import { useUserUpdate } from "./useUserUpdate";

export function UserUpdatePage() {
const navigate = useNavigate();
const userId = useNumericParam("userId");
const { requestState: getUser } = useApiRequest<User>(`/api/user/${userId}` satisfies USER_GET_REQUEST_PATH);
const { error, update, saving } = useUserUpdate(userId);

if (getUser.status === "api-error") {
throw getUser.error;
}

if (getUser.status === "loading") {
return <Loader />;
}

const user = getUser.data;

function handleSave(userUpdate: UpdateUserRequest) {
void update(userUpdate).then(({ username }) => {
const updatedMessage = t("users.user_updated_details", { username });
void navigate(`/users?updated=${encodeURIComponent(updatedMessage)}`);
});
}

function handleAbort() {
void navigate("/users");
}

return (
<>
<PageTitle title={`${user.username} - Abacus`} />
<header>
<section>
<h1>{user.fullname || user.username}</h1>
</section>
</header>

<main>
{error && (
<FormLayout.Alert>
<Alert type="error">{error.message}</Alert>
</FormLayout.Alert>
)}

<UserUpdateForm user={user} onSave={handleSave} onAbort={handleAbort} saving={saving} />
</main>
</>
);
}
1 change: 1 addition & 0 deletions frontend/app/module/users/update/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./UserUpdatePage";
Loading

0 comments on commit aa2f385

Please sign in to comment.