-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add update user form and page (#1086)
- Loading branch information
Showing
15 changed files
with
418 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
107
frontend/app/module/users/update/UserUpdateForm.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
</> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)}`); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
</> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./UserUpdatePage"; |
Oops, something went wrong.