diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 19fa737d5..302bc1c81 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,8 @@ import { createRoot } from "react-dom/client"; import { AironeSnackbarProvider } from "AironeSnackbarProvider"; import { AppRouter } from "AppRouter"; +import { CheckTermsService } from "CheckTermsService"; +import { ErrorHandler } from "ErrorHandler"; import { theme } from "Theme"; import "i18n/config"; @@ -11,7 +13,11 @@ const App: FC = () => { return ( - + + + + + ); diff --git a/frontend/src/AppRouter.tsx b/frontend/src/AppRouter.tsx index 237273f98..aa88ea0d6 100644 --- a/frontend/src/AppRouter.tsx +++ b/frontend/src/AppRouter.tsx @@ -1,8 +1,12 @@ import React, { FC } from "react"; -import { RouteComponentProps } from "react-router"; -import { Route, BrowserRouter as Router, Switch } from "react-router-dom"; +import { + createBrowserRouter, + createRoutesFromElements, + Outlet, + Route, + RouterProvider, +} from "react-router-dom"; -import { ErrorHandler } from "./ErrorHandler"; import { ACLHistoryPage } from "./pages/ACLHistoryPage"; import { EntryCopyPage } from "./pages/EntryCopyPage"; import { EntryDetailsPage } from "./pages/EntryDetailsPage"; @@ -11,7 +15,6 @@ import { NotFoundErrorPage } from "./pages/NotFoundErrorPage"; import { RoleEditPage } from "./pages/RoleEditPage"; import { RoleListPage } from "./pages/RoleListPage"; -import { CheckTermsService } from "CheckTermsService"; import { aclHistoryPath, aclPath, @@ -68,110 +71,98 @@ interface Props { customRoutes?: { path: string; routePath: string; - component?: FC; - render?: ( - props: RouteComponentProps<{ [K: string]: string | undefined }> - ) => React.ReactNode; + element: React.ReactNode; }[]; } export const AppRouter: FC = ({ customRoutes }) => { - return ( - - - - - - + const router = createBrowserRouter( + createRoutesFromElements( + + } /> +
- - {customRoutes && - customRoutes.map((r) => ( - - - - - - ))} + + + } + > + {customRoutes && + customRoutes.map((r) => ( + + + + ))} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + } /> + } + /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } /> + } + /> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ) ); + + return ; }; diff --git a/frontend/src/ErrorHandler.tsx b/frontend/src/ErrorHandler.tsx index 23aac0100..8a268ee48 100644 --- a/frontend/src/ErrorHandler.tsx +++ b/frontend/src/ErrorHandler.tsx @@ -9,7 +9,7 @@ import { import { styled } from "@mui/material/styles"; import React, { FC, useCallback, useEffect, useState } from "react"; import { ErrorBoundary, FallbackProps } from "react-error-boundary"; -import { useHistory } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { useError } from "react-use"; import { ForbiddenErrorPage } from "./pages/ForbiddenErrorPage"; @@ -46,16 +46,16 @@ interface GenericErrorProps { } const GenericError: FC = ({ children }) => { - const history = useHistory(); + const navigate = useNavigate(); const [open, setOpen] = useState(true); const handleGoToTop = useCallback(() => { - history.replace(topPath()); - }, [history]); + navigate(topPath(), { replace: true }); + }, [navigate]); const handleReload = useCallback(() => { - history.go(0); - }, [history]); + navigate(0); + }, [navigate]); return ( setOpen(false)}> @@ -96,11 +96,11 @@ const GenericError: FC = ({ children }) => { }; const ErrorFallback: FC = ({ error, resetErrorBoundary }) => { - const history = useHistory(); + const location = useLocation(); - history.listen(() => { + useEffect(() => { resetErrorBoundary(); - }); + }, [location, resetErrorBoundary]); switch (error.name) { case ForbiddenError.errorName: diff --git a/frontend/src/components/common/ImportForm.tsx b/frontend/src/components/common/ImportForm.tsx index a3afdd3a4..a3ffc2b40 100644 --- a/frontend/src/components/common/ImportForm.tsx +++ b/frontend/src/components/common/ImportForm.tsx @@ -2,7 +2,7 @@ import { Box, Button, Input, Typography } from "@mui/material"; import Encoding from "encoding-japanese"; import { useSnackbar } from "notistack"; import React, { ChangeEvent, FC, useState } from "react"; -import { useHistory } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { isResponseError, @@ -15,7 +15,7 @@ interface Props { } export const ImportForm: FC = ({ handleImport, handleCancel }) => { - const history = useHistory(); + const navigate = useNavigate(); const [file, setFile] = useState(); const [errorMessage, setErrorMessage] = useState(""); const { enqueueSnackbar } = useSnackbar(); @@ -43,7 +43,7 @@ export const ImportForm: FC = ({ handleImport, handleCancel }) => { try { await handleImport(fileReader.result); - history.go(0); + navigate(0); } catch (e) { if (e instanceof Error && isResponseError(e)) { const reportableError = await toReportableNonFieldErrors(e); diff --git a/frontend/src/components/entity/EntityControlMenu.tsx b/frontend/src/components/entity/EntityControlMenu.tsx index 2b57109d5..27a2924ac 100644 --- a/frontend/src/components/entity/EntityControlMenu.tsx +++ b/frontend/src/components/entity/EntityControlMenu.tsx @@ -8,7 +8,7 @@ import { } from "@mui/material"; import { useSnackbar } from "notistack"; import React, { FC } from "react"; -import { Link, useHistory } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { RateLimitedClickable } from "../common/RateLimitedClickable"; @@ -43,7 +43,7 @@ export const EntityControlMenu: FC = ({ setToggle, }) => { const { enqueueSnackbar } = useSnackbar(); - const history = useHistory(); + const navigate = useNavigate(); const handleDelete = async (entityId: number) => { await aironeApiClient @@ -53,8 +53,8 @@ export const EntityControlMenu: FC = ({ variant: "success", }); // A magic to reload the entity list with keeping snackbar - history.replace(topPath()); - history.replace(entitiesPath()); + navigate(topPath(), { replace: true }); + navigate(entitiesPath(), { replace: true }); setToggle && setToggle(); }) .catch(() => { diff --git a/frontend/src/components/entry/AdvancedSearchJoinModal.tsx b/frontend/src/components/entry/AdvancedSearchJoinModal.tsx index b70bd0cc7..8c2f359c5 100644 --- a/frontend/src/components/entry/AdvancedSearchJoinModal.tsx +++ b/frontend/src/components/entry/AdvancedSearchJoinModal.tsx @@ -4,7 +4,7 @@ import { } from "@dmm-com/airone-apiclient-typescript-fetch"; import { Autocomplete, Box, Button, TextField } from "@mui/material"; import React, { FC, useState } from "react"; -import { useHistory } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { AironeModal } from "components/common/AironeModal"; import { useAsyncWithThrow } from "hooks/useAsyncWithThrow"; @@ -28,7 +28,7 @@ export const AdvancedSearchJoinModal: FC = ({ handleClose, refreshSearchResults, }) => { - const history = useHistory(); + const navigate = useNavigate(); // This is join attributes that have been already been selected before. const currentAttrInfo: AdvancedSearchJoinAttrInfo | undefined = joinAttrs.find((attr) => attr.name === targetAttrname); @@ -73,7 +73,7 @@ export const AdvancedSearchJoinModal: FC = ({ refreshSearchResults(); // Update Page URL parameters - history.push({ + navigate({ pathname: location.pathname, search: "?" + params.toString(), }); diff --git a/frontend/src/components/entry/AdvancedSearchModal.tsx b/frontend/src/components/entry/AdvancedSearchModal.tsx index d1f4c5029..7698779e5 100644 --- a/frontend/src/components/entry/AdvancedSearchModal.tsx +++ b/frontend/src/components/entry/AdvancedSearchModal.tsx @@ -4,7 +4,7 @@ import { } from "@dmm-com/airone-apiclient-typescript-fetch"; import { Box, Autocomplete, Checkbox, TextField, Button } from "@mui/material"; import React, { Dispatch, FC, useState, SetStateAction } from "react"; -import { useHistory } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { formatAdvancedSearchParams } from "../../services/entry/AdvancedSearch"; import { AironeModal } from "../common/AironeModal"; @@ -24,7 +24,7 @@ export const AdvancedSearchModal: FC = ({ initialAttrNames, attrInfos, }) => { - const history = useHistory(); + const navigate = useNavigate(); const params = new URLSearchParams(location.search); const [selectedAttrNames, setSelectedAttrNames] = useState(initialAttrNames); @@ -53,11 +53,11 @@ export const AdvancedSearchModal: FC = ({ }); // Update Page URL parameters - history.push({ + navigate({ pathname: location.pathname, search: "?" + params.toString(), }); - history.go(0); + navigate(0); }; return ( diff --git a/frontend/src/components/entry/AttributeValue.tsx b/frontend/src/components/entry/AttributeValue.tsx index a39993933..f67785bb4 100644 --- a/frontend/src/components/entry/AttributeValue.tsx +++ b/frontend/src/components/entry/AttributeValue.tsx @@ -93,7 +93,7 @@ const ElemGroup: FC<{ attrValue: EntryAttributeValueGroup | undefined }> = ({ attrValue, }) => { return attrValue ? ( - + {attrValue.name} ) : ( diff --git a/frontend/src/components/entry/EntryControlMenu.tsx b/frontend/src/components/entry/EntryControlMenu.tsx index 74964a3d1..cbfa5e671 100644 --- a/frontend/src/components/entry/EntryControlMenu.tsx +++ b/frontend/src/components/entry/EntryControlMenu.tsx @@ -9,7 +9,7 @@ import { } from "@mui/material"; import { useSnackbar } from "notistack"; import React, { FC } from "react"; -import { Link, useHistory } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { entryEditPath, @@ -54,7 +54,7 @@ export const EntryControlMenu: FC = ({ customACLHistoryPath, }) => { const { enqueueSnackbar } = useSnackbar(); - const history = useHistory(); + const navigate = useNavigate(); const handleDelete = async (entryId: number) => { try { @@ -63,8 +63,8 @@ export const EntryControlMenu: FC = ({ variant: "success", }); setToggle && setToggle(); - history.replace(topPath()); - history.replace(entityEntriesPath(entityId)); + navigate(topPath(), { replace: true }); + navigate(entityEntriesPath(entityId), { replace: true }); } catch (e) { enqueueSnackbar("アイテムの削除が失敗しました", { variant: "error", diff --git a/frontend/src/components/entry/EntryHistoryList.tsx b/frontend/src/components/entry/EntryHistoryList.tsx index a4114bb4e..584e408d4 100644 --- a/frontend/src/components/entry/EntryHistoryList.tsx +++ b/frontend/src/components/entry/EntryHistoryList.tsx @@ -12,7 +12,7 @@ import { import { styled } from "@mui/material/styles"; import { useSnackbar } from "notistack"; import React, { FC, useCallback } from "react"; -import { useHistory } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { AttributeValue } from "./AttributeValue"; @@ -56,7 +56,7 @@ export const EntryHistoryList: FC = ({ changePage, }) => { const { enqueueSnackbar } = useSnackbar(); - const history = useHistory(); + const navigate = useNavigate(); const handleRestore = useCallback(async (prevAttrValueId: number) => { try { @@ -64,8 +64,8 @@ export const EntryHistoryList: FC = ({ enqueueSnackbar(`変更の復旧が完了しました`, { variant: "success", }); - history.replace(topPath()); - history.replace(showEntryHistoryPath(entityId, entryId)); + navigate(topPath(), { replace: true }); + navigate(showEntryHistoryPath(entityId, entryId), { replace: true }); } catch (e) { enqueueSnackbar(`変更の復旧が失敗しました`, { variant: "error", diff --git a/frontend/src/components/entry/EntryList.tsx b/frontend/src/components/entry/EntryList.tsx index cb068b3fc..9efd49e8f 100644 --- a/frontend/src/components/entry/EntryList.tsx +++ b/frontend/src/components/entry/EntryList.tsx @@ -1,7 +1,7 @@ import AddIcon from "@mui/icons-material/Add"; import { Box, Button, Grid } from "@mui/material"; import React, { FC, useState } from "react"; -import { Link, useHistory, useLocation } from "react-router-dom"; +import { Link, useNavigate, useLocation } from "react-router-dom"; import { EntryListCard } from "./EntryListCard"; @@ -22,7 +22,7 @@ interface Props { export const EntryList: FC = ({ entityId, canCreateEntry = true }) => { const location = useLocation(); - const history = useHistory(); + const navigate = useNavigate(); const [page, changePage] = usePage(); @@ -40,7 +40,7 @@ export const EntryList: FC = ({ entityId, canCreateEntry = true }) => { changePage(1); setQuery(newQuery ?? ""); - history.push({ + navigate({ pathname: location.pathname, search: newQuery ? `?query=${newQuery}` : "", }); diff --git a/frontend/src/components/entry/EntryReferral.test.tsx b/frontend/src/components/entry/EntryReferral.test.tsx index 4c7228c84..d0dd1ff0d 100644 --- a/frontend/src/components/entry/EntryReferral.test.tsx +++ b/frontend/src/components/entry/EntryReferral.test.tsx @@ -2,11 +2,11 @@ * @jest-environment jsdom */ -import { act, render, screen } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import React from "react"; -import { BrowserRouter } from "react-router-dom"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; -import { TestWrapper } from "TestWrapper"; +import { TestWrapperWithoutRoutes } from "TestWrapper"; import { EntryReferral } from "components/entry/EntryReferral"; afterEach(() => { @@ -41,13 +41,19 @@ test("should render a component with essential props", async () => { ); /* eslint-enable */ + const router = createMemoryRouter([ + { + path: "/", + element: , + }, + ]); await act(async () => { - render( - - - , - { wrapper: TestWrapper } - ); + return render(, { + wrapper: TestWrapperWithoutRoutes, + }); + }); + await waitFor(() => { + expect(screen.queryByTestId("loading")).not.toBeInTheDocument(); }); expect(screen.getByText("entry1")).toBeInTheDocument(); diff --git a/frontend/src/components/entry/RestorableEntryList.tsx b/frontend/src/components/entry/RestorableEntryList.tsx index e5cac77c1..9b89a4095 100644 --- a/frontend/src/components/entry/RestorableEntryList.tsx +++ b/frontend/src/components/entry/RestorableEntryList.tsx @@ -20,7 +20,7 @@ import { import { styled } from "@mui/material/styles"; import { useSnackbar } from "notistack"; import React, { FC, useState } from "react"; -import { useHistory, useLocation } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; import { EntryAttributes } from "./EntryAttributes"; @@ -101,7 +101,7 @@ interface Props { export const RestorableEntryList: FC = ({ entityId }) => { const location = useLocation(); - const history = useHistory(); + const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); const [page, changePage] = usePage(); @@ -129,7 +129,7 @@ export const RestorableEntryList: FC = ({ entityId }) => { changePage(1); setQuery(newQuery ?? ""); - history.push({ + navigate({ pathname: location.pathname, search: newQuery ? `?query=${newQuery}` : "", }); @@ -142,8 +142,8 @@ export const RestorableEntryList: FC = ({ entityId }) => { enqueueSnackbar("アイテムの復旧が完了しました", { variant: "success", }); - history.replace(topPath()); - history.replace(restoreEntryPath(entityId, keyword)); + navigate(topPath(), { replace: true }); + navigate(restoreEntryPath(entityId, keyword), { replace: true }); }) .catch(() => { enqueueSnackbar("アイテムの復旧が失敗しました", { diff --git a/frontend/src/components/entry/SearchResultsTableHead.tsx b/frontend/src/components/entry/SearchResultsTableHead.tsx index 286a9b3e9..c174e7175 100644 --- a/frontend/src/components/entry/SearchResultsTableHead.tsx +++ b/frontend/src/components/entry/SearchResultsTableHead.tsx @@ -22,7 +22,7 @@ import React, { useReducer, useState, } from "react"; -import { useHistory, useLocation } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; import { AdvancedSearchJoinModal } from "./AdvancedSearchJoinModal"; import { SearchResultControlMenu } from "./SearchResultControlMenu"; @@ -75,7 +75,7 @@ export const SearchResultsTableHead: FC = ({ refreshSearchResults, }) => { const location = useLocation(); - const history = useHistory(); + const navigate = useNavigate(); const [entryFilter, entryFilterDispatcher] = useReducer( ( @@ -170,7 +170,7 @@ export const SearchResultsTableHead: FC = ({ } // simply reload with the new params - history.push({ + navigate({ pathname: location.pathname, search: "?" + newParams.toString(), }); diff --git a/frontend/src/components/group/GroupControlMenu.tsx b/frontend/src/components/group/GroupControlMenu.tsx index 3e5274302..d02ab5fd9 100644 --- a/frontend/src/components/group/GroupControlMenu.tsx +++ b/frontend/src/components/group/GroupControlMenu.tsx @@ -9,7 +9,7 @@ import { } from "@mui/material"; import { useSnackbar } from "notistack"; import React, { FC, useCallback } from "react"; -import { Link, useHistory } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { groupPath, groupsPath, topPath } from "Routes"; import { Confirmable } from "components/common/Confirmable"; @@ -29,7 +29,7 @@ export const GroupControlMenu: FC = ({ setToggle, }) => { const { enqueueSnackbar } = useSnackbar(); - const history = useHistory(); + const navigate = useNavigate(); const handleDelete = useCallback(async () => { try { @@ -37,15 +37,15 @@ export const GroupControlMenu: FC = ({ enqueueSnackbar(`グループの削除が完了しました`, { variant: "success", }); - history.replace(topPath()); - history.replace(groupsPath()); + navigate(topPath(), { replace: true }); + navigate(groupsPath(), { replace: true }); setToggle && setToggle(); } catch (e) { enqueueSnackbar("グループの削除が失敗しました", { variant: "error", }); } - }, [history, enqueueSnackbar, groupId]); + }, [navigate, enqueueSnackbar, groupId]); return ( = ({ jobs }) => { - const history = useHistory(); + const navigate = useNavigate(); const [encodes, setEncodes] = useState<{ [key: number]: string; @@ -115,12 +115,12 @@ export const JobList: FC = ({ jobs }) => { const handleRerun = async (jobId: number) => { await aironeApiClient.rerunJob(jobId); - history.go(0); + navigate(0); }; const handleCancel = async (jobId: number) => { await aironeApiClient.cancelJob(jobId); - history.go(0); + navigate(0); }; return ( diff --git a/frontend/src/components/role/RoleList.tsx b/frontend/src/components/role/RoleList.tsx index 819209039..ccb32fd2d 100644 --- a/frontend/src/components/role/RoleList.tsx +++ b/frontend/src/components/role/RoleList.tsx @@ -18,7 +18,7 @@ import { OverridableComponent } from "@mui/material/OverridableComponent"; import { styled } from "@mui/material/styles"; import { useSnackbar } from "notistack"; import React, { FC, useState } from "react"; -import { Link, useHistory } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { useAsyncWithThrow } from "../../hooks/useAsyncWithThrow"; import { aironeApiClient } from "../../repository/AironeApiClient"; @@ -40,7 +40,7 @@ const StyledIconButton = styled(IconButton)(({ theme }) => ({ })) as OverridableComponent>; export const RoleList: FC = ({}) => { - const history = useHistory(); + const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); const [toggle, setToggle] = useState(false); @@ -54,8 +54,8 @@ export const RoleList: FC = ({}) => { enqueueSnackbar(`ロールの削除が完了しました`, { variant: "success", }); - history.replace(topPath()); - history.replace(rolesPath()); + navigate(topPath(), { replace: true }); + navigate(rolesPath(), { replace: true }); setToggle(!toggle); } catch (e) { enqueueSnackbar("ロールの削除が失敗しました", { diff --git a/frontend/src/components/user/UserControlMenu.tsx b/frontend/src/components/user/UserControlMenu.tsx index 06b56b5f1..b19463e89 100644 --- a/frontend/src/components/user/UserControlMenu.tsx +++ b/frontend/src/components/user/UserControlMenu.tsx @@ -10,7 +10,7 @@ import { } from "@mui/material"; import { useSnackbar } from "notistack"; import React, { FC, useState } from "react"; -import { useHistory } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { UserPasswordFormModal } from "./UserPasswordFormModal"; @@ -33,7 +33,7 @@ export const UserControlMenu: FC = ({ setToggle, }) => { const { enqueueSnackbar } = useSnackbar(); - const history = useHistory(); + const navigate = useNavigate(); const [openModal, setOpenModal] = useState(false); @@ -51,8 +51,8 @@ export const UserControlMenu: FC = ({ enqueueSnackbar(`ユーザ(${user.username})の削除が完了しました`, { variant: "success", }); - history.replace(topPath()); - history.replace(usersPath()); + navigate(topPath(), { replace: true }); + navigate(usersPath(), { replace: true }); setToggle && setToggle(); } catch (e) { enqueueSnackbar("ユーザの削除が失敗しました", { diff --git a/frontend/src/components/user/UserList.test.tsx b/frontend/src/components/user/UserList.test.tsx index 17cbc8dfe..34febcb82 100644 --- a/frontend/src/components/user/UserList.test.tsx +++ b/frontend/src/components/user/UserList.test.tsx @@ -10,9 +10,8 @@ import { waitFor, waitForElementToBeRemoved, } from "@testing-library/react"; -import { createMemoryHistory } from "history"; import React from "react"; -import { Router } from "react-router-dom"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; import { TestWrapper, TestWrapperWithoutRoutes } from "TestWrapper"; import { UserList } from "components/user/UserList"; @@ -62,15 +61,17 @@ describe("UserList", () => { }); test("should navigate to user create page", async () => { - const history = createMemoryHistory(); + const router = createMemoryRouter([ + { + path: "/", + element: , + }, + ]); await act(async () => { - render( - - - , - { wrapper: TestWrapperWithoutRoutes } - ); + render(, { + wrapper: TestWrapperWithoutRoutes, + }); }); await waitFor(() => { @@ -81,19 +82,21 @@ describe("UserList", () => { screen.getByRole("link", { name: "新規ユーザを登録" }).click(); }); - expect(history.location.pathname).toBe("/ui/users/new"); + expect(router.state.location.pathname).toBe("/ui/users/new"); }); test("should navigate to user details page", async function () { - const history = createMemoryHistory(); + const router = createMemoryRouter([ + { + path: "/", + element: , + }, + ]); await act(async () => { - render( - - - , - { wrapper: TestWrapperWithoutRoutes } - ); + render(, { + wrapper: TestWrapperWithoutRoutes, + }); }); await waitFor(() => { @@ -104,7 +107,7 @@ describe("UserList", () => { screen.getByRole("link", { name: "user1" }).click(); }); - expect(history.location.pathname).toBe("/ui/users/1"); + expect(router.state.location.pathname).toBe("/ui/users/1"); }); test("should delete a user", async function () { diff --git a/frontend/src/components/user/UserList.tsx b/frontend/src/components/user/UserList.tsx index 283328a78..0cf8c557d 100644 --- a/frontend/src/components/user/UserList.tsx +++ b/frontend/src/components/user/UserList.tsx @@ -13,7 +13,7 @@ import { } from "@mui/material"; import { styled } from "@mui/material/styles"; import React, { FC, useMemo, useState } from "react"; -import { Link, useHistory, useLocation } from "react-router-dom"; +import { Link, useNavigate, useLocation } from "react-router-dom"; import { UserControlMenu } from "./UserControlMenu"; @@ -47,7 +47,7 @@ const UserName = styled(Typography)(({}) => ({ export const UserList: FC = ({}) => { const location = useLocation(); - const history = useHistory(); + const navigate = useNavigate(); const [page, changePage] = usePage(); const params = new URLSearchParams(location.search); const [query, setQuery] = useState(params.get("query") ?? ""); @@ -72,7 +72,7 @@ export const UserList: FC = ({}) => { changePage(1); setQuery(newQuery ?? ""); - history.push({ + navigate({ pathname: location.pathname, search: newQuery ? `?query=${newQuery}` : "", }); diff --git a/frontend/src/components/user/UserPasswordFormModal.tsx b/frontend/src/components/user/UserPasswordFormModal.tsx index 708c5d7f9..406fda701 100644 --- a/frontend/src/components/user/UserPasswordFormModal.tsx +++ b/frontend/src/components/user/UserPasswordFormModal.tsx @@ -2,7 +2,7 @@ import { Box, Button, TextField } from "@mui/material"; import { styled } from "@mui/material/styles"; import { useSnackbar } from "notistack"; import React, { FC, useMemo, useState } from "react"; -import { useHistory } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { aironeApiClient } from "../../repository/AironeApiClient"; import { AironeModal } from "../common/AironeModal"; @@ -42,7 +42,7 @@ export const UserPasswordFormModal: FC = ({ onClose, }) => { const { enqueueSnackbar } = useSnackbar(); - const history = useHistory(); + const navigate = useNavigate(); const [oldPassword, setOldPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); @@ -77,10 +77,10 @@ export const UserPasswordFormModal: FC = ({ } if (ServerContext.getInstance()?.user?.id == userId) { - history.replace(loginPath()); + navigate(loginPath(), { replace: true }); } else { - history.replace(topPath()); - history.replace(usersPath()); + navigate(topPath(), { replace: true }); + navigate(usersPath(), { replace: true }); } } catch (e) { enqueueSnackbar( diff --git a/frontend/src/hooks/usePage.tsx b/frontend/src/hooks/usePage.tsx index a10a437a9..892b9bcae 100644 --- a/frontend/src/hooks/usePage.tsx +++ b/frontend/src/hooks/usePage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { useHistory, useLocation } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; /** * A hook provides page number to perform pagination. @@ -7,7 +7,7 @@ import { useHistory, useLocation } from "react-router-dom"; */ export const usePage = (): [number, (page: number) => void] => { const location = useLocation(); - const history = useHistory(); + const navigate = useNavigate(); const [page, setPage] = useState(1); @@ -18,12 +18,12 @@ export const usePage = (): [number, (page: number) => void] => { const params = new URLSearchParams(location.search); params.set("page", newPage.toString()); - history.push({ + navigate({ pathname: location.pathname, search: params.toString(), }); }, - [location.pathname, location.search] + [location.pathname, location.search, navigate] ); useEffect(() => { diff --git a/frontend/src/hooks/usePrompt.tsx b/frontend/src/hooks/usePrompt.tsx new file mode 100644 index 000000000..dac07c259 --- /dev/null +++ b/frontend/src/hooks/usePrompt.tsx @@ -0,0 +1,19 @@ +import { useEffect } from "react"; +import { useBlocker } from "react-router-dom"; + +export const usePrompt = (when: boolean, message: string) => { + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => + when && currentLocation.pathname !== nextLocation.pathname + ); + + useEffect(() => { + if (blocker.state === "blocked") { + if (window.confirm(message)) { + blocker.proceed(); + } else { + blocker.reset(); + } + } + }, [blocker, message]); +}; diff --git a/frontend/src/hooks/useSimpleSearch.tsx b/frontend/src/hooks/useSimpleSearch.tsx index c30d42f6c..e7f00aaf6 100644 --- a/frontend/src/hooks/useSimpleSearch.tsx +++ b/frontend/src/hooks/useSimpleSearch.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo } from "react"; -import { useHistory, useLocation } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; import { topPath } from "../Routes"; @@ -7,7 +7,7 @@ type Query = string | undefined; export const useSimpleSearch = (): [Query, (query: Query) => void] => { const location = useLocation(); - const history = useHistory(); + const navigate = useNavigate(); const query = useMemo(() => { const params = new URLSearchParams(location.search); @@ -16,12 +16,12 @@ export const useSimpleSearch = (): [Query, (query: Query) => void] => { const submitQuery = useCallback( (query: Query) => { - history.push({ + navigate({ pathname: topPath(), search: query != null ? `simple_search_query=${query}` : undefined, }); }, - [history] + [navigate] ); return [query, submitQuery]; diff --git a/frontend/src/hooks/useTypedParams.tsx b/frontend/src/hooks/useTypedParams.tsx index da90e2d8f..5a62b82dc 100644 --- a/frontend/src/hooks/useTypedParams.tsx +++ b/frontend/src/hooks/useTypedParams.tsx @@ -1,7 +1,16 @@ import { useParams } from "react-router-dom"; export const useTypedParams = < - Params extends { [K in keyof Params]?: any } + Params extends { [K in keyof Params]: any } >() => { - return useParams(); + const params = useParams() as unknown as Partial; + + const allFieldsDefined = Object.keys(params).every( + (key) => params[key as keyof Params] !== undefined + ); + if (!allFieldsDefined) { + throw new Error("Some required URL parameters are missing"); + } + + return params as Required; }; diff --git a/frontend/src/pages/ACLEditPage.test.tsx b/frontend/src/pages/ACLEditPage.test.tsx index f30716f42..f5f59c186 100644 --- a/frontend/src/pages/ACLEditPage.test.tsx +++ b/frontend/src/pages/ACLEditPage.test.tsx @@ -2,14 +2,11 @@ * @jest-environment jsdom */ -import { - render, - waitForElementToBeRemoved, - screen, -} from "@testing-library/react"; +import { render, screen, act, waitFor } from "@testing-library/react"; import React from "react"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; -import { TestWrapper } from "TestWrapper"; +import { TestWrapperWithoutRoutes } from "TestWrapper"; import { ACLEditPage } from "pages/ACLEditPage"; afterEach(() => { @@ -57,11 +54,20 @@ test("should match snapshot", async () => { .mockResolvedValue(Promise.resolve(acl)); /* eslint-enable */ - // wait async calls and get rendered fragment - const result = render(, { - wrapper: TestWrapper, + const router = createMemoryRouter([ + { + path: "/", + element: , + }, + ]); + const result = await act(async () => { + return render(, { + wrapper: TestWrapperWithoutRoutes, + }); + }); + await waitFor(() => { + expect(screen.queryByTestId("loading")).not.toBeInTheDocument(); }); - await waitForElementToBeRemoved(screen.getByTestId("loading")); expect(result).toMatchSnapshot(); }); diff --git a/frontend/src/pages/ACLEditPage.tsx b/frontend/src/pages/ACLEditPage.tsx index b76fc9b9d..1ffb9b161 100644 --- a/frontend/src/pages/ACLEditPage.tsx +++ b/frontend/src/pages/ACLEditPage.tsx @@ -8,7 +8,7 @@ import { Box, Container } from "@mui/material"; import { useSnackbar } from "notistack"; import React, { FC, useCallback, useEffect, useState } from "react"; import { FieldErrors, useForm } from "react-hook-form"; -import { Prompt, useHistory } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useAsyncWithThrow } from "../hooks/useAsyncWithThrow"; @@ -20,11 +20,12 @@ import { PageHeader } from "components/common/PageHeader"; import { SubmitButton } from "components/common/SubmitButton"; import { EntityBreadcrumbs } from "components/entity/EntityBreadcrumbs"; import { EntryBreadcrumbs } from "components/entry/EntryBreadcrumbs"; +import { usePrompt } from "hooks/usePrompt"; import { useTypedParams } from "hooks/useTypedParams"; import { aironeApiClient } from "repository/AironeApiClient"; export const ACLEditPage: FC = () => { - const history = useHistory(); + const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); const { objectId } = useTypedParams<{ objectId: number }>(); const [entity, setEntity] = useState(); @@ -42,6 +43,11 @@ export const ACLEditPage: FC = () => { mode: "onSubmit", }); + usePrompt( + isDirty && !isSubmitSuccessful, + "編集した内容は失われてしまいますが、このページを離れてもよろしいですか?" + ); + const acl = useAsyncWithThrow(async () => { return await aironeApiClient.getAcl(objectId); }); @@ -50,17 +56,19 @@ export const ACLEditPage: FC = () => { switch (acl.value?.objtype) { case ACLObjtypeEnum.Entity: if (entity?.id) { - history.replace(entityEntriesPath(entity?.id)); + navigate(entityEntriesPath(entity?.id), { replace: true }); } break; case ACLObjtypeEnum.EntityAttr: if (entity?.id) { - history.replace(editEntityPath(entity?.id)); + navigate(editEntityPath(entity?.id), { replace: true }); } break; case ACLObjtypeEnum.Entry: if (entry?.id) { - history.replace(entryDetailsPath(entry?.schema.id, entry?.id)); + navigate(entryDetailsPath(entry?.schema.id, entry?.id), { + replace: true, + }); } break; } @@ -169,11 +177,6 @@ export const ACLEditPage: FC = () => { )} - - ); }; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 676b59848..f2aaed085 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -2,7 +2,7 @@ import { Box, Container, Typography, TypographyTypeMap } from "@mui/material"; import { OverridableComponent } from "@mui/material/OverridableComponent"; import { styled } from "@mui/material/styles"; import React, { FC } from "react"; -import { Link, useHistory, useLocation } from "react-router-dom"; +import { Link, useNavigate, useLocation } from "react-router-dom"; import { useAsyncWithThrow } from "../hooks/useAsyncWithThrow"; @@ -38,7 +38,7 @@ const ResultEntityForEntry = styled(Typography)(({}) => ({ })); export const DashboardPage: FC = () => { - const history = useHistory(); + const navigate = useNavigate(); const location = useLocation(); const [query, submitQuery] = useSimpleSearch(); @@ -55,7 +55,7 @@ export const DashboardPage: FC = () => { // If there is only one search result, move to entry details page. if (!entries.loading && entries.value?.length === 1) { - history.push( + navigate( entryDetailsPath(entries.value[0].schema?.id ?? 0, entries.value[0].id) ); } diff --git a/frontend/src/pages/EntityEditPage.test.tsx b/frontend/src/pages/EntityEditPage.test.tsx index 7e3926745..4ee1e473b 100644 --- a/frontend/src/pages/EntityEditPage.test.tsx +++ b/frontend/src/pages/EntityEditPage.test.tsx @@ -6,15 +6,11 @@ import { EntityDetail, PaginatedEntityListList, } from "@dmm-com/airone-apiclient-typescript-fetch"; -import { - render, - screen, - waitForElementToBeRemoved, -} from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import React from "react"; -import { MemoryRouter, Route } from "react-router-dom"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; import { editEntityPath } from "../Routes"; @@ -84,16 +80,25 @@ describe("EditEntityPage", () => { }); test("should match snapshot", async () => { - // wait async calls and get rendered fragment - const result = render( - - - , + const router = createMemoryRouter( + [ + { + path: editEntityPath(":entityId"), + element: , + }, + ], { - wrapper: TestWrapperWithoutRoutes, + initialEntries: ["/ui/entities/1"], } ); - await waitForElementToBeRemoved(screen.getByTestId("loading")); + const result = await act(async () => { + return render(, { + wrapper: TestWrapperWithoutRoutes, + }); + }); + await waitFor(() => { + expect(screen.queryByTestId("loading")).not.toBeInTheDocument(); + }); expect(result).toMatchSnapshot(); }); diff --git a/frontend/src/pages/EntityEditPage.tsx b/frontend/src/pages/EntityEditPage.tsx index 69d4ae0f9..fb937f1b0 100644 --- a/frontend/src/pages/EntityEditPage.tsx +++ b/frontend/src/pages/EntityEditPage.tsx @@ -3,7 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Box } from "@mui/material"; import React, { FC, useEffect } from "react"; import { useForm } from "react-hook-form"; -import { Prompt, useHistory } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { entitiesPath, entityEntriesPath } from "Routes"; import { Loading } from "components/common/Loading"; @@ -14,6 +14,7 @@ import { EntityForm } from "components/entity/EntityForm"; import { Schema, schema } from "components/entity/entityForm/EntityFormSchema"; import { useAsyncWithThrow } from "hooks/useAsyncWithThrow"; import { useFormNotification } from "hooks/useFormNotification"; +import { usePrompt } from "hooks/usePrompt"; import { useTypedParams } from "hooks/useTypedParams"; import { aironeApiClient } from "repository/AironeApiClient"; import { @@ -27,7 +28,7 @@ export const EntityEditPage: FC = () => { const willCreate = entityId === undefined; - const history = useHistory(); + const navigate = useNavigate(); const { enqueueSubmitResult } = useFormNotification("モデル", willCreate); const { @@ -42,6 +43,11 @@ export const EntityEditPage: FC = () => { mode: "onBlur", }); + usePrompt( + isDirty && !isSubmitSuccessful, + "編集した内容は失われてしまいますが、このページを離れてもよろしいですか?" + ); + const entity = useAsyncWithThrow(async () => { if (entityId !== undefined) { return await aironeApiClient.getEntity(entityId); @@ -57,9 +63,9 @@ export const EntityEditPage: FC = () => { const handleCancel = () => { if (entityId !== undefined) { - history.replace(entityEntriesPath(entityId)); + navigate(entityEntriesPath(entityId), { replace: true }); } else { - history.replace(entitiesPath()); + navigate(entitiesPath(), { replace: true }); } }; @@ -172,9 +178,9 @@ export const EntityEditPage: FC = () => { useEffect(() => { if (isSubmitSuccessful) { if (entityId === undefined) { - history.replace(entitiesPath()); + navigate(entitiesPath(), { replace: true }); } else { - history.replace(entityEntriesPath(entityId)); + navigate(entityEntriesPath(entityId), { replace: true }); } } }, [isSubmitSuccessful]); @@ -211,11 +217,6 @@ export const EntityEditPage: FC = () => { control={control} setValue={setValue} /> - - ); }; diff --git a/frontend/src/pages/EntityListPage.tsx b/frontend/src/pages/EntityListPage.tsx index 06eeba130..3bdca1069 100644 --- a/frontend/src/pages/EntityListPage.tsx +++ b/frontend/src/pages/EntityListPage.tsx @@ -1,6 +1,6 @@ import { Box, Button, Container, Typography } from "@mui/material"; import React, { FC, useCallback, useState } from "react"; -import { Link, useHistory, useLocation } from "react-router-dom"; +import { Link, useNavigate, useLocation } from "react-router-dom"; import { useAsyncWithThrow } from "../hooks/useAsyncWithThrow"; @@ -15,7 +15,7 @@ import { aironeApiClient } from "repository/AironeApiClient"; export const EntityListPage: FC = () => { const location = useLocation(); - const history = useHistory(); + const navigate = useNavigate(); const [page, changePage] = usePage(); @@ -33,7 +33,7 @@ export const EntityListPage: FC = () => { changePage(1); setQuery(newQuery ?? ""); - history.push({ + navigate({ pathname: location.pathname, search: newQuery ? `?query=${newQuery}` : "", }); diff --git a/frontend/src/pages/EntryCopyPage.test.tsx b/frontend/src/pages/EntryCopyPage.test.tsx index 0a00128b3..5fa2effc0 100644 --- a/frontend/src/pages/EntryCopyPage.test.tsx +++ b/frontend/src/pages/EntryCopyPage.test.tsx @@ -2,16 +2,13 @@ * @jest-environment jsdom */ -import { - render, - waitForElementToBeRemoved, - screen, -} from "@testing-library/react"; +import { render, screen, act, waitFor } from "@testing-library/react"; import React from "react"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; import { EntryCopyPage } from "./EntryCopyPage"; -import { TestWrapper } from "TestWrapper"; +import { TestWrapperWithoutRoutes } from "TestWrapper"; afterEach(() => { jest.clearAllMocks(); @@ -34,11 +31,20 @@ test("should match snapshot", async () => { .mockResolvedValue(Promise.resolve(entry)); /* eslint-enable */ - // wait async calls and get rendered fragment - const result = render(, { - wrapper: TestWrapper, + const router = createMemoryRouter([ + { + path: "/", + element: , + }, + ]); + const result = await act(async () => { + return render(, { + wrapper: TestWrapperWithoutRoutes, + }); + }); + await waitFor(() => { + expect(screen.queryByTestId("loading")).not.toBeInTheDocument(); }); - await waitForElementToBeRemoved(screen.getByTestId("loading")); expect(result).toMatchSnapshot(); }); diff --git a/frontend/src/pages/EntryCopyPage.tsx b/frontend/src/pages/EntryCopyPage.tsx index 72389e259..0cc5c1872 100644 --- a/frontend/src/pages/EntryCopyPage.tsx +++ b/frontend/src/pages/EntryCopyPage.tsx @@ -1,7 +1,7 @@ import { Box, Container } from "@mui/material"; import { useSnackbar } from "notistack"; import React, { FC, useState } from "react"; -import { Prompt, useHistory } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useAsyncWithThrow } from "../hooks/useAsyncWithThrow"; import { useTypedParams } from "../hooks/useTypedParams"; @@ -15,6 +15,7 @@ import { CopyFormProps, } from "components/entry/CopyForm"; import { EntryBreadcrumbs } from "components/entry/EntryBreadcrumbs"; +import { usePrompt } from "hooks/usePrompt"; import { aironeApiClient } from "repository/AironeApiClient"; interface Props { @@ -22,7 +23,7 @@ interface Props { } export const EntryCopyPage: FC = ({ CopyForm = DefaultCopyForm }) => { - const history = useHistory(); + const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); const { entityId, entryId } = useTypedParams<{ entityId: number; @@ -35,6 +36,11 @@ export const EntryCopyPage: FC = ({ CopyForm = DefaultCopyForm }) => { const [submitted, setSubmitted] = useState(false); const [edited, setEdited] = useState(false); + usePrompt( + edited && !submitted, + "編集した内容は失われてしまいますが、このページを離れてもよろしいですか?" + ); + const entry = useAsyncWithThrow(async () => { return await aironeApiClient.getEntry(entryId); }, [entryId]); @@ -57,7 +63,7 @@ export const EntryCopyPage: FC = ({ CopyForm = DefaultCopyForm }) => { variant: "success", }); setTimeout(() => { - history.replace(entityEntriesPath(entityId)); + navigate(entityEntriesPath(entityId), { replace: true }); }, 0.1); } catch { enqueueSnackbar("アイテムコピーのジョブ登録が失敗しました", { @@ -67,8 +73,9 @@ export const EntryCopyPage: FC = ({ CopyForm = DefaultCopyForm }) => { }; const handleCancel = () => { - history.replace( - entryDetailsPath(entry.value?.schema?.id ?? 0, entry.value?.id ?? 0) + navigate( + entryDetailsPath(entry.value?.schema?.id ?? 0, entry.value?.id ?? 0), + { replace: true } ); }; @@ -98,11 +105,6 @@ export const EntryCopyPage: FC = ({ CopyForm = DefaultCopyForm }) => { /> )} - - ); }; diff --git a/frontend/src/pages/EntryDetailsPage.test.tsx b/frontend/src/pages/EntryDetailsPage.test.tsx index fc071bce7..2b6fb3595 100644 --- a/frontend/src/pages/EntryDetailsPage.test.tsx +++ b/frontend/src/pages/EntryDetailsPage.test.tsx @@ -2,16 +2,12 @@ * @jest-environment jsdom */ -import { - render, - waitForElementToBeRemoved, - screen, -} from "@testing-library/react"; +import { render, screen, act, waitFor } from "@testing-library/react"; import React from "react"; -import { MemoryRouter, Route } from "react-router-dom"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; import { entryDetailsPath } from "Routes"; -import { TestWrapper } from "TestWrapper"; +import { TestWrapperWithoutRoutes } from "TestWrapper"; import { EntryDetailsPage } from "pages/EntryDetailsPage"; afterEach(() => { @@ -59,19 +55,25 @@ test("should match snapshot", async () => { .mockResolvedValue(Promise.resolve(entry)); /* eslint-enable */ - // wait async calls and get rendered fragment - const result = render( - - - , + const router = createMemoryRouter( + [ + { + path: entryDetailsPath(":entityId", ":entryId"), + element: , + }, + ], { - wrapper: TestWrapper, + initialEntries: ["/ui/entities/2/entries/1/details"], } ); - await waitForElementToBeRemoved(screen.getByTestId("loading")); + const result = await act(async () => { + return render(, { + wrapper: TestWrapperWithoutRoutes, + }); + }); + await waitFor(() => { + expect(screen.queryByTestId("loading")).not.toBeInTheDocument(); + }); expect(result).toMatchSnapshot(); diff --git a/frontend/src/pages/EntryDetailsPage.tsx b/frontend/src/pages/EntryDetailsPage.tsx index 81ca360c5..8569b4249 100644 --- a/frontend/src/pages/EntryDetailsPage.tsx +++ b/frontend/src/pages/EntryDetailsPage.tsx @@ -3,7 +3,7 @@ import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; import { Box, Chip, Grid, IconButton, Stack, Typography } from "@mui/material"; import { styled } from "@mui/material/styles"; import React, { FC, useEffect, useState } from "react"; -import { useHistory } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useAsyncWithThrow } from "../hooks/useAsyncWithThrow"; import { useTypedParams } from "../hooks/useTypedParams"; @@ -74,7 +74,7 @@ export const EntryDetailsPage: FC = ({ entityId: number; entryId: number; }>(); - const history = useHistory(); + const navigate = useNavigate(); const [entryAnchorEl, setEntryAnchorEl] = useState( null @@ -87,13 +87,19 @@ export const EntryDetailsPage: FC = ({ useEffect(() => { // When user specifies invalid entityId, redirect to the page that is correct entityId if (!entry.loading && entry.value?.schema?.id != entityId) { - history.replace(entryDetailsPath(entry.value?.schema?.id ?? 0, entryId)); + navigate(entryDetailsPath(entry.value?.schema?.id ?? 0, entryId), { + replace: true, + }); } // If it'd been deleted, show restore-entry page instead if (!entry.loading && entry.value?.isActive === false) { - history.replace( - restoreEntryPath(entry.value?.schema?.id ?? "", entry.value?.name ?? "") + navigate( + restoreEntryPath( + entry.value?.schema?.id ?? "", + entry.value?.name ?? "" + ), + { replace: true } ); } }, [entry.loading]); diff --git a/frontend/src/pages/EntryEditPage.test.tsx b/frontend/src/pages/EntryEditPage.test.tsx index 74d749b1f..171a12b2f 100644 --- a/frontend/src/pages/EntryEditPage.test.tsx +++ b/frontend/src/pages/EntryEditPage.test.tsx @@ -2,20 +2,16 @@ * @jest-environment jsdom */ -import { - render, - waitForElementToBeRemoved, - screen, -} from "@testing-library/react"; +import { render, screen, act, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import React from "react"; -import { MemoryRouter, Route } from "react-router-dom"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; import { EntryEditPage } from "./EntryEditPage"; import { entryEditPath } from "Routes"; -import { TestWrapper } from "TestWrapper"; +import { TestWrapperWithoutRoutes } from "TestWrapper"; const server = setupServer( // getEntity @@ -61,19 +57,25 @@ describe("EntryEditPage", () => { }); test("should match snapshot", async () => { - // wait async calls and get rendered fragment - const result = render( - - - , + const router = createMemoryRouter( + [ + { + path: entryEditPath(":entityId", ":entryId"), + element: , + }, + ], { - wrapper: TestWrapper, + initialEntries: ["/ui/entities/2/entries/1/edit"], } ); - await waitForElementToBeRemoved(screen.getByTestId("loading")); + const result = await act(async () => { + return render(, { + wrapper: TestWrapperWithoutRoutes, + }); + }); + await waitFor(() => { + expect(screen.queryByTestId("loading")).not.toBeInTheDocument(); + }); expect(result).toMatchSnapshot(); }); diff --git a/frontend/src/pages/EntryEditPage.tsx b/frontend/src/pages/EntryEditPage.tsx index b800f824a..8ea2ec41f 100644 --- a/frontend/src/pages/EntryEditPage.tsx +++ b/frontend/src/pages/EntryEditPage.tsx @@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Box } from "@mui/material"; import React, { FC, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; -import { Prompt, useHistory } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useAsyncWithThrow } from "../hooks/useAsyncWithThrow"; @@ -18,6 +18,7 @@ import { } from "components/entry/EntryForm"; import { Schema, schema } from "components/entry/entryForm/EntryFormSchema"; import { useFormNotification } from "hooks/useFormNotification"; +import { usePrompt } from "hooks/usePrompt"; import { useTypedParams } from "hooks/useTypedParams"; import { aironeApiClient } from "repository/AironeApiClient"; import { @@ -45,7 +46,7 @@ export const EntryEditPage: FC = ({ const willCreate = entryId == null; - const history = useHistory(); + const navigate = useNavigate(); const { enqueueSubmitResult } = useFormNotification("アイテム", willCreate); const [initialized, setInitialized] = useState(false); @@ -62,6 +63,11 @@ export const EntryEditPage: FC = ({ mode: "onBlur", }); + usePrompt( + isDirty && !isSubmitSuccessful, + "編集した内容は失われてしまいますが、このページを離れてもよろしいですか?" + ); + const entity = useAsyncWithThrow(async () => { return await aironeApiClient.getEntity(entityId); }); @@ -104,9 +110,9 @@ export const EntryEditPage: FC = ({ useEffect(() => { if (isSubmitSuccessful) { if (willCreate) { - history.replace(entityEntriesPath(entityId)); + navigate(entityEntriesPath(entityId), { replace: true }); } else { - history.replace(entryDetailsPath(entityId, entryId)); + navigate(entryDetailsPath(entityId, entryId), { replace: true }); } } }, [isSubmitSuccessful]); @@ -140,9 +146,9 @@ export const EntryEditPage: FC = ({ const handleCancel = () => { if (willCreate) { - history.replace(entityEntriesPath(entityId)); + navigate(entityEntriesPath(entityId), { replace: true }); } else { - history.replace(entryDetailsPath(entityId, entryId)); + navigate(entryDetailsPath(entityId, entryId), { replace: true }); } }; @@ -194,11 +200,6 @@ export const EntryEditPage: FC = ({ setValue={setValue} /> )} - - ); }; diff --git a/frontend/src/pages/EntryHistoryListPage.test.tsx b/frontend/src/pages/EntryHistoryListPage.test.tsx index fbe5927c5..481b4b957 100644 --- a/frontend/src/pages/EntryHistoryListPage.test.tsx +++ b/frontend/src/pages/EntryHistoryListPage.test.tsx @@ -2,19 +2,15 @@ * @jest-environment jsdom */ -import { - render, - waitForElementToBeRemoved, - screen, -} from "@testing-library/react"; +import { render, screen, act, waitFor } from "@testing-library/react"; import React from "react"; -import { MemoryRouter, Route } from "react-router-dom"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; import { showEntryHistoryPath } from "../Routes"; import { EntryHistoryListPage } from "./EntryHistoryListPage"; -import { TestWrapper } from "TestWrapper"; +import { TestWrapperWithoutRoutes } from "TestWrapper"; afterEach(() => { jest.clearAllMocks(); @@ -50,19 +46,25 @@ test("should match snapshot", async () => { .mockResolvedValue(Promise.resolve(histories)); /* eslint-enable */ - // wait async calls and get rendered fragment - const result = render( - - - , + const router = createMemoryRouter( + [ + { + path: showEntryHistoryPath(":entityId", ":entryId"), + element: , + }, + ], { - wrapper: TestWrapper, + initialEntries: [showEntryHistoryPath(2, 1)], } ); - await waitForElementToBeRemoved(screen.getByTestId("loading")); + const result = await act(async () => { + return render(, { + wrapper: TestWrapperWithoutRoutes, + }); + }); + await waitFor(() => { + expect(screen.queryByTestId("loading")).not.toBeInTheDocument(); + }); expect(result).toMatchSnapshot(); diff --git a/frontend/src/pages/GroupEditPage.test.tsx b/frontend/src/pages/GroupEditPage.test.tsx index f18f74490..7e3fa9849 100644 --- a/frontend/src/pages/GroupEditPage.test.tsx +++ b/frontend/src/pages/GroupEditPage.test.tsx @@ -2,16 +2,13 @@ * @jest-environment jsdom */ -import { - render, - waitForElementToBeRemoved, - screen, -} from "@testing-library/react"; +import { render, screen, act, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import React from "react"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; -import { TestWrapper } from "TestWrapper"; +import { TestWrapperWithoutRoutes } from "TestWrapper"; import { GroupEditPage } from "pages/GroupEditPage"; const server = setupServer( @@ -96,11 +93,20 @@ describe("EditGroupPage", () => { }); test("should match snapshot", async () => { - // wait async calls and get rendered fragment - const result = render(, { - wrapper: TestWrapper, + const router = createMemoryRouter([ + { + path: "/", + element: , + }, + ]); + const result = await act(async () => { + return render(, { + wrapper: TestWrapperWithoutRoutes, + }); + }); + await waitFor(() => { + expect(screen.queryByTestId("loading")).not.toBeInTheDocument(); }); - await waitForElementToBeRemoved(screen.getByTestId("loading")); expect(result).toMatchSnapshot(); }); diff --git a/frontend/src/pages/GroupEditPage.tsx b/frontend/src/pages/GroupEditPage.tsx index 30b1df0bc..cc6184ea7 100644 --- a/frontend/src/pages/GroupEditPage.tsx +++ b/frontend/src/pages/GroupEditPage.tsx @@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod/dist/zod"; import { Box, Container, Typography } from "@mui/material"; import React, { FC, useEffect } from "react"; import { useForm } from "react-hook-form"; -import { Link, Prompt, useHistory } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { useAsyncWithThrow } from "../hooks/useAsyncWithThrow"; @@ -13,6 +13,7 @@ import { SubmitButton } from "components/common/SubmitButton"; import { GroupForm } from "components/group/GroupForm"; import { schema, Schema } from "components/group/groupForm/GroupFormSchema"; import { useFormNotification } from "hooks/useFormNotification"; +import { usePrompt } from "hooks/usePrompt"; import { useTypedParams } from "hooks/useTypedParams"; import { aironeApiClient } from "repository/AironeApiClient"; import { @@ -26,7 +27,7 @@ export const GroupEditPage: FC = () => { const { groupId } = useTypedParams<{ groupId?: number }>(); const willCreate = groupId == null; - const history = useHistory(); + const navigate = useNavigate(); const { enqueueSubmitResult } = useFormNotification("グループ", willCreate); const { @@ -41,6 +42,11 @@ export const GroupEditPage: FC = () => { mode: "onBlur", }); + usePrompt( + isDirty && !isSubmitSuccessful, + "編集した内容は失われてしまいますが、このページを離れてもよろしいですか?" + ); + const group = useAsyncWithThrow(async () => { return groupId != null ? await aironeApiClient.getGroup(groupId) @@ -82,11 +88,11 @@ export const GroupEditPage: FC = () => { }; useEffect(() => { - isSubmitSuccessful && history.replace(groupsPath()); + isSubmitSuccessful && navigate(groupsPath(), { replace: true }); }, [isSubmitSuccessful]); const handleCancel = async () => { - history.goBack(); + navigate(-1); }; if (ServerContext.getInstance()?.user?.isSuperuser !== true) { @@ -125,11 +131,6 @@ export const GroupEditPage: FC = () => { groupId={Number(groupId)} /> - - ); }; diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 8166704e2..4eb95dda6 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -19,7 +19,7 @@ import { Typography, } from "@mui/material"; import React, { FC, useCallback, useEffect, useState } from "react"; -import { useHistory } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; import { PasswordResetConfirmModal } from "../components/user/PasswordResetConfirmModal"; import { PasswordResetModal } from "../components/user/PasswordResetModal"; @@ -29,7 +29,8 @@ import { ServerContext } from "services/ServerContext"; export const LoginPage: FC = () => { const serverContext = ServerContext.getInstance(); - const history = useHistory(); + const navigate = useNavigate(); + const location = useLocation(); const [showPassword, setShowPassword] = useState(false); const [isAlert, setIsAlert] = useState(false); @@ -56,10 +57,13 @@ export const LoginPage: FC = () => { if (_token != null) { setToken(_token); params.delete("token"); - history.replace({ - pathname: location.pathname, - search: "?" + params.toString(), - }); + navigate( + { + pathname: location.pathname, + search: "?" + params.toString(), + }, + { replace: true } + ); } if (_uidb64 != null && _token != null) { diff --git a/frontend/src/pages/RoleEditPage.test.tsx b/frontend/src/pages/RoleEditPage.test.tsx index 3d959c981..3aa7fa255 100644 --- a/frontend/src/pages/RoleEditPage.test.tsx +++ b/frontend/src/pages/RoleEditPage.test.tsx @@ -2,18 +2,15 @@ * @jest-environment jsdom */ -import { - render, - screen, - waitForElementToBeRemoved, -} from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import React from "react"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; import { RoleEditPage } from "./RoleEditPage"; -import { TestWrapper } from "TestWrapper"; +import { TestWrapperWithoutRoutes } from "TestWrapper"; const server = setupServer( // getRole @@ -93,11 +90,20 @@ afterAll(() => server.close()); describe("EditRolePage", () => { test("should match snapshot", async () => { - // wait async calls and get rendered fragment - const result = render(, { - wrapper: TestWrapper, + const router = createMemoryRouter([ + { + path: "/", + element: , + }, + ]); + const result = await act(async () => { + return render(, { + wrapper: TestWrapperWithoutRoutes, + }); + }); + await waitFor(() => { + expect(screen.queryByTestId("loading")).not.toBeInTheDocument(); }); - await waitForElementToBeRemoved(screen.getByTestId("loading")); expect(result).toMatchSnapshot(); }); diff --git a/frontend/src/pages/RoleEditPage.tsx b/frontend/src/pages/RoleEditPage.tsx index 943b886b2..d4dcd0aa1 100644 --- a/frontend/src/pages/RoleEditPage.tsx +++ b/frontend/src/pages/RoleEditPage.tsx @@ -3,7 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Box, Container, Typography } from "@mui/material"; import React, { FC, useCallback, useEffect } from "react"; import { useForm } from "react-hook-form"; -import { Link, Prompt, useHistory } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { useAsyncWithThrow } from "../hooks/useAsyncWithThrow"; @@ -15,6 +15,7 @@ import { SubmitButton } from "components/common/SubmitButton"; import { RoleForm } from "components/role/RoleForm"; import { Schema, schema } from "components/role/roleForm/RoleFormSchema"; import { useFormNotification } from "hooks/useFormNotification"; +import { usePrompt } from "hooks/usePrompt"; import { useTypedParams } from "hooks/useTypedParams"; import { aironeApiClient } from "repository/AironeApiClient"; import { @@ -26,7 +27,7 @@ export const RoleEditPage: FC = () => { const { roleId } = useTypedParams<{ roleId?: number }>(); const willCreate = roleId == null; - const history = useHistory(); + const navigate = useNavigate(); const { enqueueSubmitResult } = useFormNotification("ロール", willCreate); const { @@ -41,6 +42,11 @@ export const RoleEditPage: FC = () => { mode: "onBlur", }); + usePrompt( + isDirty && !isSubmitSuccessful, + "編集した内容は失われてしまいますが、このページを離れてもよろしいですか?" + ); + const role = useAsyncWithThrow(async () => { return roleId != null ? await aironeApiClient.getRole(roleId) : undefined; }, [roleId]); @@ -50,7 +56,7 @@ export const RoleEditPage: FC = () => { }, [role.loading]); useEffect(() => { - isSubmitSuccessful && history.push(rolesPath()); + isSubmitSuccessful && navigate(rolesPath()); }, [isSubmitSuccessful]); const handleSubmitOnValid = useCallback( @@ -89,7 +95,7 @@ export const RoleEditPage: FC = () => { ); const handleCancel = async () => { - history.goBack(); + navigate(-1); }; if (role.loading) { @@ -130,11 +136,6 @@ export const RoleEditPage: FC = () => { - - ); }; diff --git a/frontend/src/pages/TriggerEditPage.test.tsx b/frontend/src/pages/TriggerEditPage.test.tsx index 7c057999c..a9202d086 100644 --- a/frontend/src/pages/TriggerEditPage.test.tsx +++ b/frontend/src/pages/TriggerEditPage.test.tsx @@ -2,17 +2,17 @@ * @jest-environment jsdom */ -import { act, render } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import React from "react"; -import { MemoryRouter, Route } from "react-router-dom"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; import { editTriggerPath } from "../Routes"; import { TriggerEditPage } from "./TriggerEditPage"; -import { TestWrapper } from "TestWrapper"; +import { TestWrapperWithoutRoutes } from "TestWrapper"; const server = setupServer( // getTrigger @@ -84,18 +84,24 @@ afterAll(() => server.close()); describe("EditTriggerPage", () => { test("should match snapshot", async () => { - const result = await act(async () => { - return render( - - - , + const router = createMemoryRouter( + [ { - wrapper: TestWrapper, - } - ); + path: editTriggerPath(":triggerId"), + element: , + }, + ], + { + initialEntries: ["/ui/triggers/1"], + } + ); + const result = await act(async () => { + return render(, { + wrapper: TestWrapperWithoutRoutes, + }); + }); + await waitFor(() => { + expect(screen.queryByTestId("loading")).not.toBeInTheDocument(); }); expect(result).toMatchSnapshot(); diff --git a/frontend/src/pages/TriggerEditPage.tsx b/frontend/src/pages/TriggerEditPage.tsx index 5034a9c20..87a486bf7 100644 --- a/frontend/src/pages/TriggerEditPage.tsx +++ b/frontend/src/pages/TriggerEditPage.tsx @@ -18,7 +18,7 @@ import { import { styled } from "@mui/material/styles"; import React, { FC, useCallback, useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; -import { Link, Prompt, useHistory } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import type { TriggerActionUpdate } from "@dmm-com/airone-apiclient-typescript-fetch/src/autogenerated/models/TriggerActionUpdate"; @@ -31,6 +31,7 @@ import { Conditions } from "components/trigger/Conditions"; import { Schema, schema } from "components/trigger/TriggerFormSchema"; import { useAsyncWithThrow } from "hooks/useAsyncWithThrow"; import { useFormNotification } from "hooks/useFormNotification"; +import { usePrompt } from "hooks/usePrompt"; import { useTypedParams } from "hooks/useTypedParams"; import { aironeApiClient } from "repository/AironeApiClient"; @@ -66,7 +67,7 @@ export const TriggerEditPage: FC = () => { const { triggerId } = useTypedParams<{ triggerId: number }>(); const willCreate = triggerId === undefined; - const history = useHistory(); + const navigate = useNavigate(); const { enqueueSubmitResult } = useFormNotification("トリガー", willCreate); const actionTrigger = useAsyncWithThrow(async () => { @@ -95,6 +96,11 @@ export const TriggerEditPage: FC = () => { }, }); + usePrompt( + isDirty && !isSubmitSuccessful, + "編集した内容は失われてしまいますが、このページを離れてもよろしいですか?" + ); + const entities = useAsyncWithThrow(async () => { const entities = await aironeApiClient.getEntities(); return entities.results; @@ -251,7 +257,7 @@ export const TriggerEditPage: FC = () => { ); const handleCancel = async () => { - history.goBack(); + navigate(-1); }; useEffect(() => { @@ -283,7 +289,7 @@ export const TriggerEditPage: FC = () => { useEffect(() => { if (isSubmitSuccessful) { - history.replace(triggersPath()); + navigate(triggersPath(), { replace: true }); } }, [isSubmitSuccessful]); @@ -425,10 +431,6 @@ export const TriggerEditPage: FC = () => { )} - ); }; diff --git a/frontend/src/pages/TriggerListPage.tsx b/frontend/src/pages/TriggerListPage.tsx index 54066a67c..fd8cd6bd7 100644 --- a/frontend/src/pages/TriggerListPage.tsx +++ b/frontend/src/pages/TriggerListPage.tsx @@ -29,7 +29,7 @@ import { OverridableComponent } from "@mui/material/OverridableComponent"; import { styled } from "@mui/material/styles"; import { useSnackbar } from "notistack"; import React, { FC, useState } from "react"; -import { Link, useHistory } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { useAsync } from "react-use"; import { @@ -207,7 +207,7 @@ const TriggerAction: FC<{ }; export const TriggerListPage: FC = () => { - const history = useHistory(); + const navigate = useNavigate(); const [toggle, setToggle] = useState(false); const { enqueueSnackbar } = useSnackbar(); @@ -221,8 +221,8 @@ export const TriggerListPage: FC = () => { enqueueSnackbar(`トリガーの削除が完了しました`, { variant: "success", }); - history.replace(topPath()); - history.replace(triggersPath()); + navigate(topPath(), { replace: true }); + navigate(triggersPath(), { replace: true }); setToggle(!toggle); } catch (e) { enqueueSnackbar("トリガーの削除が失敗しました", { diff --git a/frontend/src/pages/UserEditPage.test.tsx b/frontend/src/pages/UserEditPage.test.tsx index 90215d033..1dc75d67a 100644 --- a/frontend/src/pages/UserEditPage.test.tsx +++ b/frontend/src/pages/UserEditPage.test.tsx @@ -2,16 +2,13 @@ * @jest-environment jsdom */ -import { - render, - waitForElementToBeRemoved, - screen, -} from "@testing-library/react"; +import { render, screen, act, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import React from "react"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; -import { TestWrapper } from "TestWrapper"; +import { TestWrapperWithoutRoutes } from "TestWrapper"; import { UserEditPage } from "pages/UserEditPage"; const server = setupServer( @@ -43,11 +40,20 @@ describe("EditUserPage", () => { }); test("should match snapshot", async () => { - // wait async calls and get rendered fragment - const result = render(, { - wrapper: TestWrapper, + const router = createMemoryRouter([ + { + path: "/", + element: , + }, + ]); + const result = await act(async () => { + return render(, { + wrapper: TestWrapperWithoutRoutes, + }); + }); + await waitFor(() => { + expect(screen.queryByTestId("loading")).not.toBeInTheDocument(); }); - await waitForElementToBeRemoved(screen.getByTestId("loading")); expect(result).toMatchSnapshot(); }); diff --git a/frontend/src/pages/UserEditPage.tsx b/frontend/src/pages/UserEditPage.tsx index 243749909..146206226 100644 --- a/frontend/src/pages/UserEditPage.tsx +++ b/frontend/src/pages/UserEditPage.tsx @@ -3,8 +3,7 @@ import { Box, Typography, Button, Container } from "@mui/material"; import { useSnackbar } from "notistack"; import React, { FC, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; -import { Link, Prompt } from "react-router-dom"; -import { useHistory } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { useToggle } from "react-use"; import { topPath, usersPath } from "Routes"; @@ -17,6 +16,7 @@ import { UserPasswordFormModal } from "components/user/UserPasswordFormModal"; import { schema, Schema } from "components/user/userForm/UserFormSchema"; import { useAsyncWithThrow } from "hooks/useAsyncWithThrow"; import { useFormNotification } from "hooks/useFormNotification"; +import { usePrompt } from "hooks/usePrompt"; import { useTypedParams } from "hooks/useTypedParams"; import { aironeApiClient } from "repository/AironeApiClient"; import { @@ -29,7 +29,7 @@ export const UserEditPage: FC = () => { const { userId } = useTypedParams<{ userId?: number }>(); const willCreate = userId == null; - const history = useHistory(); + const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); const { enqueueSubmitResult } = useFormNotification("ユーザ", willCreate); const [shouldRefresh, toggleShouldRefresh] = useToggle(false); @@ -45,6 +45,11 @@ export const UserEditPage: FC = () => { mode: "onBlur", }); + usePrompt( + isDirty && !isSubmitSuccessful, + "編集した内容は失われてしまいますが、このページを離れてもよろしいですか?" + ); + const user = useAsyncWithThrow(async () => { if (userId) { return await aironeApiClient.getUser(userId); @@ -56,7 +61,7 @@ export const UserEditPage: FC = () => { }, [user.value]); useEffect(() => { - isSubmitSuccessful && history.push(usersPath()); + isSubmitSuccessful && navigate(usersPath()); }, [isSubmitSuccessful]); // These state variables and handlers are used for password reset feature @@ -118,7 +123,7 @@ export const UserEditPage: FC = () => { }; const handleCancel = () => { - history.replace(usersPath()); + navigate(usersPath()); }; const handleRefreshToken = async () => { @@ -208,11 +213,6 @@ export const UserEditPage: FC = () => { /> )} - - ); }; diff --git a/frontend/src/pages/__snapshots__/ACLEditPage.test.tsx.snap b/frontend/src/pages/__snapshots__/ACLEditPage.test.tsx.snap index b04179583..a779f68f9 100644 --- a/frontend/src/pages/__snapshots__/ACLEditPage.test.tsx.snap +++ b/frontend/src/pages/__snapshots__/ACLEditPage.test.tsx.snap @@ -269,6 +269,71 @@ exports[`should match snapshot 1`] = ` + + + member1 + + + +
+ + + + +
+ + @@ -542,6 +607,71 @@ exports[`should match snapshot 1`] = ` + + + member1 + + + +
+ + + + +
+ + diff --git a/frontend/src/pages/__snapshots__/EntityEditPage.test.tsx.snap b/frontend/src/pages/__snapshots__/EntityEditPage.test.tsx.snap index 65078bf4f..c6237e784 100644 --- a/frontend/src/pages/__snapshots__/EntityEditPage.test.tsx.snap +++ b/frontend/src/pages/__snapshots__/EntityEditPage.test.tsx.snap @@ -142,6 +142,9 @@ exports[`EditEntityPage should match snapshot 1`] = ` type="button" > キャンセル + @@ -216,7 +219,7 @@ exports[`EditEntityPage should match snapshot 1`] = ` placeholder="モデル名" required="" type="text" - value="" + value="test entity" />