From aa30c1cdbbbe2feadb86cad5f20082c65b6c8597 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 26 Jul 2026 17:04:43 +0900 Subject: [PATCH 1/2] feat(user): wire user delete into UserGetContainer and UserProfileView --- .../user/components/UserProfileView.tsx | 64 ++++++++++++++++++- .../user/containers/user-get-container.tsx | 17 ++++- client/src/locals/en/ui.json | 6 +- client/src/locals/ja/ui.json | 6 +- 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/client/src/app/features/user/components/UserProfileView.tsx b/client/src/app/features/user/components/UserProfileView.tsx index fde5c66..058c3ff 100644 --- a/client/src/app/features/user/components/UserProfileView.tsx +++ b/client/src/app/features/user/components/UserProfileView.tsx @@ -1,5 +1,9 @@ import { useEffect, useRef, useState } from "react"; import type { FormEvent } from "react"; +import Dialog from "@mui/material/Dialog"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import DialogActions from "@mui/material/DialogActions"; import TextField from "@shared/uis/TextField.tsx"; import { Button } from "@shared/uis/Button.tsx"; import { ErrorPanel } from "@shared/uis/ErrorPanel.tsx"; @@ -12,6 +16,9 @@ type Props = { onUpdate: (values: UpdateUserFormValues) => void; isUpdating: boolean; updateError: unknown; + onDelete: () => void; + isDeleting: boolean; + deleteError: unknown; }; type EditableField = keyof UpdateUserFormValues; @@ -25,11 +32,21 @@ const toFormValues = (profile: UserProfile): UpdateUserFormValues => ({ email: profile.email, }); -export function UserProfileView({ profile, onUpdate, isUpdating, updateError }: Props) { +export function UserProfileView({ + profile, + onUpdate, + isUpdating, + updateError, + onDelete, + isDeleting, + deleteError, +}: Props) { const text = useText(); const [editingField, setEditingField] = useState(null); const [fieldValue, setFieldValue] = useState(""); + const [isConfirmingDelete, setIsConfirmingDelete] = useState(false); const wasUpdatingRef = useRef(false); + const wasDeletingRef = useRef(false); useEffect(() => { if (wasUpdatingRef.current && !isUpdating && updateError == null) { @@ -38,6 +55,13 @@ export function UserProfileView({ profile, onUpdate, isUpdating, updateError }: wasUpdatingRef.current = isUpdating; }, [isUpdating, updateError]); + useEffect(() => { + if (wasDeletingRef.current && !isDeleting && deleteError != null) { + setIsConfirmingDelete(false); + } + wasDeletingRef.current = isDeleting; + }, [isDeleting, deleteError]); + const startEditing = (field: EditableField) => { setFieldValue(profile[field]); setEditingField(field); @@ -53,6 +77,10 @@ export function UserProfileView({ profile, onUpdate, isUpdating, updateError }: onUpdate({ ...toFormValues(profile), [editingField]: fieldValue }); }; + const handleConfirmDelete = () => { + onDelete(); + }; + return (
@@ -118,6 +146,40 @@ export function UserProfileView({ profile, onUpdate, isUpdating, updateError }:
+ + {deleteError != null && } + + + + setIsConfirmingDelete(false)}> + {text.userDeleteConfirmTitle} + {text.userDeleteConfirmMessage} + + + + +
); } diff --git a/client/src/app/features/user/containers/user-get-container.tsx b/client/src/app/features/user/containers/user-get-container.tsx index 8f9c088..cbc7bf0 100644 --- a/client/src/app/features/user/containers/user-get-container.tsx +++ b/client/src/app/features/user/containers/user-get-container.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { useGetUser } from "../hooks/use-get-user"; import { useUpdateUser } from "../hooks/use-update-user"; +import { useDeleteUser } from "../hooks/use-delete-user"; import { UserProfileView } from "../components/UserProfileView"; import { ErrorPanel } from "@shared/uis/ErrorPanel.tsx"; import { Spinner } from "@shared/uis/Spinner.tsx"; @@ -11,6 +12,7 @@ import type { UserProfile } from "../types"; export function UserGetContainer() { const { id } = useParams<{ id: string }>(); const numericId = Number(id); + const navigate = useNavigate(); const { data, isLoading, error } = useGetUser(numericId); const { submit: submitUpdate, @@ -18,6 +20,12 @@ export function UserGetContainer() { isLoading: isUpdating, error: updateError, } = useUpdateUser(); + const { + submit: submitDelete, + done: isDeleted, + isLoading: isDeleting, + error: deleteError, + } = useDeleteUser(); const [profile, setProfile] = useState(null); const text = useText(); @@ -29,6 +37,10 @@ export function UserGetContainer() { if (updated) setProfile(updated); }, [updated]); + useEffect(() => { + if (isDeleted) navigate("/heritages"); + }, [isDeleted, navigate]); + if (isLoading) return ; if (error || !profile) return ; @@ -38,6 +50,9 @@ export function UserGetContainer() { onUpdate={(values) => submitUpdate(numericId, values)} isUpdating={isUpdating} updateError={updateError} + onDelete={() => submitDelete(numericId)} + isDeleting={isDeleting} + deleteError={deleteError} /> ); } diff --git a/client/src/locals/en/ui.json b/client/src/locals/en/ui.json index bbe2147..370f8d5 100644 --- a/client/src/locals/en/ui.json +++ b/client/src/locals/en/ui.json @@ -79,5 +79,9 @@ "userUpdateEdit": "Edit", "userUpdateSubmit": "Save", "userUpdateCancel": "Cancel", - "userUpdateError": "Failed to update user." + "userUpdateError": "Failed to update user.", + "userDeleteButton": "Delete User", + "userDeleteConfirmTitle": "Delete User", + "userDeleteConfirmMessage": "Are you sure you want to delete this user? This action cannot be undone.", + "userDeleteError": "Failed to delete user." } diff --git a/client/src/locals/ja/ui.json b/client/src/locals/ja/ui.json index be0e0bd..63da1ea 100644 --- a/client/src/locals/ja/ui.json +++ b/client/src/locals/ja/ui.json @@ -79,5 +79,9 @@ "userUpdateEdit": "編集", "userUpdateSubmit": "保存", "userUpdateCancel": "キャンセル", - "userUpdateError": "ユーザー情報の更新に失敗しました。" + "userUpdateError": "ユーザー情報の更新に失敗しました。", + "userDeleteButton": "ユーザーを削除", + "userDeleteConfirmTitle": "ユーザーの削除", + "userDeleteConfirmMessage": "このユーザーを削除してもよろしいですか?この操作は取り消せません。", + "userDeleteError": "ユーザーの削除に失敗しました。" } From c88a56bba8bd0c5d5d740af8a053b8d516bcd780 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 26 Jul 2026 17:04:45 +0900 Subject: [PATCH 2/2] test(user): add tests for the user delete flow --- .../__tests__/UserProfileView.test.tsx | 55 ++++++++++++++++++- .../__tests__/user-get-container.test.tsx | 46 ++++++++++++++-- 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/client/src/app/features/user/components/__tests__/UserProfileView.test.tsx b/client/src/app/features/user/components/__tests__/UserProfileView.test.tsx index fc1f7bb..573ef72 100644 --- a/client/src/app/features/user/components/__tests__/UserProfileView.test.tsx +++ b/client/src/app/features/user/components/__tests__/UserProfileView.test.tsx @@ -1,6 +1,6 @@ /** @jest-environment jsdom */ -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { LocaleProvider } from "@shared/locale/LocaleProvider.tsx"; import { UserProfileView } from "../UserProfileView"; @@ -22,6 +22,9 @@ const renderView = (props: Partial> onUpdate: jest.fn(), isUpdating: false, updateError: null, + onDelete: jest.fn(), + isDeleting: false, + deleteError: null, ...props, }; @@ -125,7 +128,14 @@ describe("UserProfileView", () => { rerender( - + , ); @@ -150,4 +160,45 @@ describe("UserProfileView", () => { expect(screen.queryByLabelText(/^First Name/)).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Edit First Name" })).toBeInTheDocument(); }); + + it("opens a confirmation dialog when Delete User is clicked", () => { + renderView(); + + fireEvent.click(screen.getByRole("button", { name: "Delete User" })); + + expect( + screen.getByText("Are you sure you want to delete this user?", { exact: false }), + ).toBeInTheDocument(); + }); + + it("closes the dialog without calling onDelete when Cancel is clicked", async () => { + const onDelete = jest.fn(); + renderView({ onDelete }); + + fireEvent.click(screen.getByRole("button", { name: "Delete User" })); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onDelete).not.toHaveBeenCalled(); + await waitFor(() => { + expect( + screen.queryByText("Are you sure you want to delete this user?", { exact: false }), + ).not.toBeInTheDocument(); + }); + }); + + it("calls onDelete when the dialog's Delete User button is confirmed", () => { + const onDelete = jest.fn(); + renderView({ onDelete }); + + fireEvent.click(screen.getByRole("button", { name: "Delete User" })); + fireEvent.click(screen.getByRole("button", { name: "Delete User" })); + + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it("shows an error panel when deleteError is present", () => { + renderView({ deleteError: new Error("boom") }); + + expect(screen.getByText("Failed to delete user.")).toBeInTheDocument(); + }); }); diff --git a/client/src/app/features/user/containers/__tests__/user-get-container.test.tsx b/client/src/app/features/user/containers/__tests__/user-get-container.test.tsx index 3879687..09bc3b2 100644 --- a/client/src/app/features/user/containers/__tests__/user-get-container.test.tsx +++ b/client/src/app/features/user/containers/__tests__/user-get-container.test.tsx @@ -4,6 +4,7 @@ import type { UserProfile } from "../../types"; const useGetUserMock = jest.fn(); const useUpdateUserMock = jest.fn(); +const useDeleteUserMock = jest.fn(); jest.mock("../../hooks/use-get-user", () => ({ useGetUser: (id: number) => useGetUserMock(id), @@ -13,6 +14,10 @@ jest.mock("../../hooks/use-update-user", () => ({ useUpdateUser: () => useUpdateUserMock(), })); +jest.mock("../../hooks/use-delete-user", () => ({ + useDeleteUser: () => useDeleteUserMock(), +})); + import { render, screen, fireEvent } from "@testing-library/react"; import { MemoryRouter, Routes, Route } from "react-router-dom"; import { LocaleProvider } from "@shared/locale/LocaleProvider.tsx"; @@ -34,22 +39,30 @@ const renderContainer = (id = "1") => } /> + Heritages Home} /> , ); describe("UserGetContainer", () => { - const submitMock = jest.fn(); + const submitUpdateMock = jest.fn(); + const submitDeleteMock = jest.fn(); beforeEach(() => { jest.clearAllMocks(); useUpdateUserMock.mockReturnValue({ - submit: submitMock, + submit: submitUpdateMock, data: null, isLoading: false, error: null, }); + useDeleteUserMock.mockReturnValue({ + submit: submitDeleteMock, + done: false, + isLoading: false, + error: null, + }); }); it("shows a spinner while loading", () => { @@ -102,7 +115,7 @@ describe("UserGetContainer", () => { fireEvent.change(screen.getByLabelText(/^First Name/), { target: { value: "Jiro" } }); fireEvent.click(screen.getByRole("button", { name: "Save" })); - expect(submitMock).toHaveBeenCalledWith( + expect(submitUpdateMock).toHaveBeenCalledWith( 42, expect.objectContaining({ firstName: "Jiro", lastName: "Yamada", email: "taro@example.com" }), ); @@ -111,7 +124,7 @@ describe("UserGetContainer", () => { it("reflects the updated profile immediately once useUpdateUser returns data", () => { useGetUserMock.mockReturnValue({ data: profile, isLoading: false, error: null }); useUpdateUserMock.mockReturnValue({ - submit: submitMock, + submit: submitUpdateMock, data: { ...profile, firstName: "Jiro" }, isLoading: false, error: null, @@ -121,4 +134,29 @@ describe("UserGetContainer", () => { expect(screen.getByText("Jiro Yamada")).toBeInTheDocument(); }); + + it("calls useDeleteUser's submit with the numeric id after confirming deletion", () => { + useGetUserMock.mockReturnValue({ data: profile, isLoading: false, error: null }); + + renderContainer("42"); + + fireEvent.click(screen.getByRole("button", { name: "Delete User" })); + fireEvent.click(screen.getByRole("button", { name: "Delete User" })); + + expect(submitDeleteMock).toHaveBeenCalledWith(42); + }); + + it("navigates to /heritages once useDeleteUser reports done", () => { + useGetUserMock.mockReturnValue({ data: profile, isLoading: false, error: null }); + useDeleteUserMock.mockReturnValue({ + submit: submitDeleteMock, + done: true, + isLoading: false, + error: null, + }); + + renderContainer(); + + expect(screen.getByText("Heritages Home")).toBeInTheDocument(); + }); });