From 601e2ca6b0ae2c3d74393da54fb14e552adff9bd Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 21 Jul 2026 09:03:02 +0900 Subject: [PATCH 1/4] feat(auth): add LoginForm UI component and i18n text --- .../features/auth/components/LoginForm.tsx | 54 +++++++++++++++ .../components/__tests__/LoginForm.test.tsx | 66 +++++++++++++++++++ client/src/locals/en/ui.json | 6 +- client/src/locals/ja/ui.json | 6 +- 4 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 client/src/app/features/auth/components/LoginForm.tsx create mode 100644 client/src/app/features/auth/components/__tests__/LoginForm.test.tsx diff --git a/client/src/app/features/auth/components/LoginForm.tsx b/client/src/app/features/auth/components/LoginForm.tsx new file mode 100644 index 0000000..5aa0cb8 --- /dev/null +++ b/client/src/app/features/auth/components/LoginForm.tsx @@ -0,0 +1,54 @@ +import type { FormEvent } from "react"; +import TextField from "@shared/uis/TextField.tsx"; +import { Button } from "@shared/uis/Button.tsx"; +import { ErrorPanel } from "@shared/uis/ErrorPanel.tsx"; +import { useText } from "@shared/locale/ui-text.ts"; +import type { LoginFormValues } from "../types"; + +type Props = { + value: LoginFormValues; + onChange: (next: LoginFormValues) => void; + onSubmit: () => void; + isLoading: boolean; + error: unknown; +}; + +export function LoginForm({ value, onChange, onSubmit, isLoading, error }: Props) { + const text = useText(); + + const handleField = (key: K, next: LoginFormValues[K]) => { + onChange({ ...value, [key]: next }); + }; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + onSubmit(); + }; + + return ( +
+

{text.login}

+ + {error != null && } + + handleField("email", e.target.value)} + required + /> + handleField("password", e.target.value)} + required + /> + + + + ); +} diff --git a/client/src/app/features/auth/components/__tests__/LoginForm.test.tsx b/client/src/app/features/auth/components/__tests__/LoginForm.test.tsx new file mode 100644 index 0000000..37f9f17 --- /dev/null +++ b/client/src/app/features/auth/components/__tests__/LoginForm.test.tsx @@ -0,0 +1,66 @@ +/** @jest-environment jsdom */ + +import { render, screen, fireEvent } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { LocaleProvider } from "@shared/locale/LocaleProvider.tsx"; +import { LoginForm } from "../LoginForm"; +import type { LoginFormValues } from "../../types"; + +const renderForm = (props: Partial> = {}) => { + const value: LoginFormValues = { email: "", password: "" }; + + const defaultProps: React.ComponentProps = { + value, + onChange: jest.fn(), + onSubmit: jest.fn(), + isLoading: false, + error: null, + ...props, + }; + + return render( + + + + + , + ); +}; + +describe("LoginForm", () => { + it("renders email and password fields", () => { + renderForm(); + + expect(screen.getByLabelText(/^Email/)).toBeInTheDocument(); + expect(screen.getByLabelText(/^Password/)).toBeInTheDocument(); + }); + + it("calls onChange with the updated field when typing", () => { + const onChange = jest.fn(); + renderForm({ onChange }); + + fireEvent.change(screen.getByLabelText(/^Email/), { + target: { value: "taro@example.com" }, + }); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ email: "taro@example.com", password: "" }), + ); + }); + + it("calls onSubmit when the form is submitted", () => { + const onSubmit = jest.fn(); + const value: LoginFormValues = { email: "taro@example.com", password: "password123" }; + renderForm({ onSubmit, value }); + + fireEvent.click(screen.getByRole("button", { name: "Login" })); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it("shows an error panel when error is present", () => { + renderForm({ error: new Error("boom") }); + + expect(screen.getByText("Failed to log in.")).toBeInTheDocument(); + }); +}); diff --git a/client/src/locals/en/ui.json b/client/src/locals/en/ui.json index 93bea0e..aa61c92 100644 --- a/client/src/locals/en/ui.json +++ b/client/src/locals/en/ui.json @@ -63,5 +63,9 @@ "viewAllResults": "View all results", "unescoCriteriaSource": "UNESCO official criteria page", "worldHeritageBasics": "World Heritage Basics", - "worldHeritageBasicsDescription": "Every site is inscribed under one or more of these 10 selection criteria. Explore what each one means." + "worldHeritageBasicsDescription": "Every site is inscribed under one or more of these 10 selection criteria. Explore what each one means.", + "userEmail": "Email", + "userPassword": "Password", + "login": "Login", + "loginError": "Failed to log in." } diff --git a/client/src/locals/ja/ui.json b/client/src/locals/ja/ui.json index 58b862e..3d250cb 100644 --- a/client/src/locals/ja/ui.json +++ b/client/src/locals/ja/ui.json @@ -63,5 +63,9 @@ "viewAllResults": "すべての結果を見る", "unescoCriteriaSource": "UNESCO 公式の登録基準ページ", "worldHeritageBasics": "世界遺産の基礎知識", - "worldHeritageBasicsDescription": "すべての世界遺産は、以下の10個の登録基準のいずれか(または複数)を満たして登録されています。各基準の意味を見てみましょう。" + "worldHeritageBasicsDescription": "すべての世界遺産は、以下の10個の登録基準のいずれか(または複数)を満たして登録されています。各基準の意味を見てみましょう。", + "userEmail": "メールアドレス", + "userPassword": "パスワード", + "login": "ログイン", + "loginError": "ログインに失敗しました。" } From a5f7fa2d67c92905aac2c3f6eec9aa11767bc17d Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 21 Jul 2026 09:14:16 +0900 Subject: [PATCH 2/4] feat(auth): add LoginContainer --- .../__tests__/login-container.test.tsx | 115 ++++++++++++++++++ .../auth/containers/login-container.tsx | 33 +++++ 2 files changed, 148 insertions(+) create mode 100644 client/src/app/features/auth/containers/__tests__/login-container.test.tsx create mode 100644 client/src/app/features/auth/containers/login-container.tsx diff --git a/client/src/app/features/auth/containers/__tests__/login-container.test.tsx b/client/src/app/features/auth/containers/__tests__/login-container.test.tsx new file mode 100644 index 0000000..97307fd --- /dev/null +++ b/client/src/app/features/auth/containers/__tests__/login-container.test.tsx @@ -0,0 +1,115 @@ +/** @jest-environment jsdom */ + +import { jest } from "@jest/globals"; + +const submitMock = jest.fn(); +const useLoginMock = jest.fn(); +const navigateMock = jest.fn(); + +jest.mock("react-router-dom", () => { + const actual = jest.requireActual("react-router-dom") as typeof import("react-router-dom"); + return { ...actual, useNavigate: () => navigateMock }; +}); + +jest.mock("../../hooks/use-login", () => ({ + useLogin: () => useLoginMock(), +})); + +jest.mock("../../components/LoginForm", () => ({ + __esModule: true, + LoginForm: function MockLoginForm(props: { + value: { email: string; password: string }; + onChange: (next: { email: string; password: string }) => void; + onSubmit: () => void; + isLoading: boolean; + error: unknown; + }) { + return ( +
+ {props.value.email} + {String(props.isLoading)} + {props.error ? "has-error" : "no-error"} + + +
+ ); + }, +})); + +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, beforeEach } from "@jest/globals"; +import { MemoryRouter } from "react-router-dom"; +import { LoginContainer } from "../login-container"; + +describe("LoginContainer", () => { + beforeEach(() => { + jest.clearAllMocks(); + useLoginMock.mockReturnValue({ submit: submitMock, isLoading: false, error: null }); + }); + + const renderContainer = () => + render( + + + , + ); + + it("renders the form with default draft values", () => { + renderContainer(); + + expect(screen.getByTestId("email").textContent).toBe(""); + expect(screen.getByTestId("is-loading").textContent).toBe("false"); + expect(screen.getByTestId("error").textContent).toBe("no-error"); + }); + + it("updates the draft when the form calls onChange", () => { + renderContainer(); + + fireEvent.click(screen.getByRole("button", { name: "change" })); + + expect(screen.getByTestId("email").textContent).toBe("taro@example.com"); + }); + + it("calls submit with the current draft and navigates to /mypage on success", async () => { + submitMock.mockResolvedValue(true); + renderContainer(); + + fireEvent.click(screen.getByRole("button", { name: "change" })); + fireEvent.click(screen.getByRole("button", { name: "submit" })); + + expect(submitMock).toHaveBeenCalledWith( + expect.objectContaining({ email: "taro@example.com", password: "" }), + ); + await waitFor(() => expect(navigateMock).toHaveBeenCalledWith("/mypage")); + }); + + it("does not navigate when submit fails", async () => { + submitMock.mockResolvedValue(false); + renderContainer(); + + fireEvent.click(screen.getByRole("button", { name: "submit" })); + + await waitFor(() => expect(submitMock).toHaveBeenCalled()); + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it("passes through isLoading and error from the hook", () => { + useLoginMock.mockReturnValue({ + submit: submitMock, + isLoading: true, + error: new Error("boom"), + }); + + renderContainer(); + + expect(screen.getByTestId("is-loading").textContent).toBe("true"); + expect(screen.getByTestId("error").textContent).toBe("has-error"); + }); +}); diff --git a/client/src/app/features/auth/containers/login-container.tsx b/client/src/app/features/auth/containers/login-container.tsx new file mode 100644 index 0000000..acf0361 --- /dev/null +++ b/client/src/app/features/auth/containers/login-container.tsx @@ -0,0 +1,33 @@ +import { useCallback, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useLogin } from "../hooks/use-login"; +import type { LoginFormValues } from "../types"; +import { LoginForm } from "../components/LoginForm"; + +const DEFAULT_LOGIN_FORM_VALUES: LoginFormValues = { email: "", password: "" }; + +export function LoginContainer() { + const [draft, setDraft] = useState(DEFAULT_LOGIN_FORM_VALUES); + const { submit, isLoading, error } = useLogin(); + const navigate = useNavigate(); + + const handleChange = useCallback((next: LoginFormValues) => { + setDraft(next); + }, []); + + const handleSubmit = useCallback(() => { + void submit(draft).then((ok) => { + if (ok) navigate("/mypage"); + }); + }, [draft, submit, navigate]); + + return ( + + ); +} From 01fa8df2329ac6f96f8a50ff1202fd2239bd012f Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 27 Jul 2026 22:12:07 +0900 Subject: [PATCH 3/4] style(auth): fix prettier formatting --- client/src/app/features/auth/apis/auth-api.test.ts | 13 ++++++++----- .../features/auth/hooks/__tests__/use-login.test.ts | 2 +- client/src/app/features/auth/hooks/use-logout.ts | 2 +- .../auth/mapper/__tests__/to-login-request.test.ts | 2 +- .../app/features/auth/mapper/to-login-request.ts | 2 +- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/client/src/app/features/auth/apis/auth-api.test.ts b/client/src/app/features/auth/apis/auth-api.test.ts index 7caf468..3bb5b9c 100644 --- a/client/src/app/features/auth/apis/auth-api.test.ts +++ b/client/src/app/features/auth/apis/auth-api.test.ts @@ -46,7 +46,10 @@ describe("createAuthApi", () => { describe("login", () => { it("posts credentials and returns the bearer token from the envelope", async () => { fetchSpy.mockResolvedValue( - makeOkResponse({ status: "success", data: { token: TOKEN, token_type: "Bearer" } }) as Response, + makeOkResponse({ + status: "success", + data: { token: TOKEN, token_type: "Bearer" }, + }) as Response, ); const result = await api.login({ email: "taro@example.com", password: "password123" }); @@ -64,9 +67,9 @@ describe("createAuthApi", () => { it("throws on HTTP error", async () => { fetchSpy.mockResolvedValue(makeNgResponse(422) as Response); - await expect( - api.login({ email: "taro@example.com", password: "wrong" }), - ).rejects.toThrow("HTTP 422"); + await expect(api.login({ email: "taro@example.com", password: "wrong" })).rejects.toThrow( + "HTTP 422", + ); }); }); @@ -117,4 +120,4 @@ describe("createAuthApi", () => { await expect(api.getCurrentUser(TOKEN)).rejects.toThrow("HTTP 500"); }); }); -}); \ No newline at end of file +}); diff --git a/client/src/app/features/auth/hooks/__tests__/use-login.test.ts b/client/src/app/features/auth/hooks/__tests__/use-login.test.ts index 5214839..6d87273 100644 --- a/client/src/app/features/auth/hooks/__tests__/use-login.test.ts +++ b/client/src/app/features/auth/hooks/__tests__/use-login.test.ts @@ -143,4 +143,4 @@ describe("useLogin", () => { expect(result.current.isLoading).toBe(false); expect(result.current.error).toBeNull(); }); -}); \ No newline at end of file +}); diff --git a/client/src/app/features/auth/hooks/use-logout.ts b/client/src/app/features/auth/hooks/use-logout.ts index 7f6c62e..a42ca91 100644 --- a/client/src/app/features/auth/hooks/use-logout.ts +++ b/client/src/app/features/auth/hooks/use-logout.ts @@ -42,4 +42,4 @@ export function useLogout() { }, [setUser]); return { submit, isLoading, error }; -} \ No newline at end of file +} diff --git a/client/src/app/features/auth/mapper/__tests__/to-login-request.test.ts b/client/src/app/features/auth/mapper/__tests__/to-login-request.test.ts index 80303fa..2145379 100644 --- a/client/src/app/features/auth/mapper/__tests__/to-login-request.test.ts +++ b/client/src/app/features/auth/mapper/__tests__/to-login-request.test.ts @@ -29,4 +29,4 @@ describe("toLoginRequest", () => { expect(request.password).toBe(" spaced out "); }); -}); \ No newline at end of file +}); diff --git a/client/src/app/features/auth/mapper/to-login-request.ts b/client/src/app/features/auth/mapper/to-login-request.ts index 16ae90e..d8cfc45 100644 --- a/client/src/app/features/auth/mapper/to-login-request.ts +++ b/client/src/app/features/auth/mapper/to-login-request.ts @@ -4,4 +4,4 @@ import type { LoginRequest } from "../apis/auth-api"; export const toLoginRequest = (values: LoginFormValues): LoginRequest => ({ email: values.email.trim(), password: values.password, -}); \ No newline at end of file +}); From e497d18b005bdd676609997713ba4035dc0d0418 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 27 Jul 2026 22:23:31 +0900 Subject: [PATCH 4/4] fix(auth): type submitMock in login-container test to fix tsc build error --- .../auth/containers/__tests__/login-container.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/app/features/auth/containers/__tests__/login-container.test.tsx b/client/src/app/features/auth/containers/__tests__/login-container.test.tsx index 97307fd..22f80af 100644 --- a/client/src/app/features/auth/containers/__tests__/login-container.test.tsx +++ b/client/src/app/features/auth/containers/__tests__/login-container.test.tsx @@ -1,8 +1,9 @@ /** @jest-environment jsdom */ import { jest } from "@jest/globals"; +import type { LoginFormValues } from "../../types"; -const submitMock = jest.fn(); +const submitMock = jest.fn<(values: LoginFormValues) => Promise>(); const useLoginMock = jest.fn(); const navigateMock = jest.fn();