diff --git a/frontend/src/pages/AliasEntryListPage.test.tsx b/frontend/src/pages/AliasEntryListPage.test.tsx new file mode 100644 index 000000000..ef2255762 --- /dev/null +++ b/frontend/src/pages/AliasEntryListPage.test.tsx @@ -0,0 +1,127 @@ +/** + * @jest-environment jsdom + */ + +import { 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"; + +import { TestWrapperWithoutRoutes } from "TestWrapper"; +import { AliasEntryListPage } from "pages/AliasEntryListPage"; + +// Setup mock location +const mockLocation = { + pathname: "/ui/entities/1/alias", + search: "", + hash: "", + state: null, +}; + +// Mock useLocation +jest.mock("react-use", () => ({ + ...jest.requireActual("react-use"), + useLocation: () => mockLocation, +})); + +const server = setupServer( + // GET /entity/api/v2/:entityId/ + http.get("http://localhost/entity/api/v2/1/", () => { + return HttpResponse.json({ + id: 1, + name: "Entity1", + note: "Test Entity", + isToplevel: true, + hasOngoingChanges: false, + attrs: [], + webhooks: [], + }); + }), + + // GET /entity/api/v2/:entityId/entries/ + http.get("http://localhost/entity/api/v2/1/entries/", ({ request }) => { + // Get query parameters (when isAlias is true) + const url = new URL(request.url); + const withAlias = url.searchParams.get("with_alias"); + + if (withAlias === "1") { + return HttpResponse.json({ + count: 2, + next: null, + previous: null, + results: [ + { + id: 1, + name: "entry1", + schema: 1, + created_at: "2023-01-01T00:00:00Z", + updated_at: "2023-01-01T00:00:00Z", + created_user: { id: 1, username: "user1" }, + updated_user: { id: 1, username: "user1" }, + attrs: [], + referrals: [], + aliases: [], + is_public: true, + status: 0, + }, + { + id: 2, + name: "entry2", + schema: 1, + created_at: "2023-01-02T00:00:00Z", + updated_at: "2023-01-02T00:00:00Z", + created_user: { id: 1, username: "user1" }, + updated_user: { id: 1, username: "user1" }, + attrs: [], + referrals: [], + aliases: [], + is_public: true, + status: 0, + }, + ], + }); + } + + return HttpResponse.json({ + count: 0, + next: null, + previous: null, + results: [], + }); + }) +); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("AliasEntryListPage", () => { + it("renders the alias entry list", async () => { + // Create memory router + const router = createMemoryRouter( + [ + { + path: "/ui/entities/:entityId/alias", + element: , + }, + ], + { + initialEntries: ["/ui/entities/1/alias"], + } + ); + + // Render component + render( + + + + ); + + // Wait for page title to be displayed + await waitFor(() => { + const titleElement = screen.getByRole("heading", { name: "Entity1" }); + expect(titleElement).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/pages/AliasEntryListPage.tsx b/frontend/src/pages/AliasEntryListPage.tsx new file mode 100644 index 000000000..9aec80655 --- /dev/null +++ b/frontend/src/pages/AliasEntryListPage.tsx @@ -0,0 +1,197 @@ +import { EntryBase } from "@dmm-com/airone-apiclient-typescript-fetch"; +import AppsIcon from "@mui/icons-material/Apps"; +import { Box, Container, Grid, IconButton } from "@mui/material"; +import { useSnackbar } from "notistack"; +import React, { FC, useEffect, useState } from "react"; +import { useNavigate } from "react-router"; +import { useLocation } from "react-use"; + +import { useAsyncWithThrow } from "../hooks/useAsyncWithThrow"; +import { useTypedParams } from "../hooks/useTypedParams"; + +import { PaginationFooter } from "components"; +import { PageHeader } from "components/common/PageHeader"; +import { SearchBox } from "components/common/SearchBox"; +import { EntityBreadcrumbs } from "components/entity/EntityBreadcrumbs"; +import { EntityControlMenu } from "components/entity/EntityControlMenu"; +import { AliasEntryList } from "components/entry/AliasEntryList"; +import { EntryImportModal } from "components/entry/EntryImportModal"; +import { useFormNotification, usePage } from "hooks"; +import { aironeApiClient } from "repository/AironeApiClient"; +import { + EntryListParam, + extractAPIException, + isResponseError, + normalizeToMatch, +} from "services"; + +export const AliasEntryListPage: FC = ({}) => { + const { entityId } = useTypedParams<{ + entityId: number; + }>(); + + const location = useLocation(); + const params = new URLSearchParams(location.search); + const navigate = useNavigate(); + const { enqueueSubmitResult } = useFormNotification("エイリアス", true); + const { enqueueSnackbar } = useSnackbar(); + const [page, changePage] = usePage(); + const [query, setQuery] = useState(params.get("query") ?? ""); + + const [entityAnchorEl, setEntityAnchorEl] = + useState(null); + const [openImportModal, setOpenImportModal] = React.useState(false); + + const [entries, setEntries] = useState([]); + const [totalCount, setTotalCount] = useState(0); + + const entity = useAsyncWithThrow(async () => { + return await aironeApiClient.getEntity(entityId); + }, [entityId]); + + useEffect(() => { + aironeApiClient + .getEntries(entityId, true, page, query, true) + .then((res) => { + setEntries(res.results); + setTotalCount(res.count); + }); + }, [page, query]); + + const handleChangeQuery = (newQuery?: string) => { + changePage(1); + setQuery(newQuery ?? ""); + + navigate({ + pathname: location.pathname, + search: newQuery ? `?query=${newQuery}` : "", + }); + }; + + const handleCreate = (entryId: number, target: HTMLInputElement) => { + const name = target.value; + aironeApiClient + .createEntryAlias(entryId, name) + .then((resp) => { + setEntries( + entries.map((entry) => { + if (entry.id === entryId) { + return { + ...entry, + aliases: [...entry.aliases, resp], + }; + } else { + return entry; + } + }) + ); + target.value = ""; + enqueueSubmitResult(true); + }) + .catch((e) => { + if (e instanceof Error && isResponseError(e)) { + extractAPIException( + e, + (message) => enqueueSubmitResult(false, `詳細: "${message}"`), + (name, message) => enqueueSubmitResult(false, `詳細: "${message}"`) + ); + } else { + enqueueSubmitResult(false); + } + }); + }; + + const handleDelete = (id: number) => { + aironeApiClient + .deleteEntryAlias(id) + .then(() => { + setEntries( + entries.map((entry) => { + return { + ...entry, + aliases: entry.aliases.filter((alias) => alias.id !== id), + }; + }) + ); + enqueueSnackbar("エイリアスの削除が完了しました。", { + variant: "success", + }); + }) + .catch(() => { + enqueueSnackbar("エイリアスの削除が失敗しました。", { + variant: "error", + }); + }); + }; + + return ( + + + + + + { + setEntityAnchorEl(e.currentTarget); + }} + > + + + setEntityAnchorEl(null)} + setOpenImportModal={setOpenImportModal} + /> + + + + + + { + e.key === "Enter" && + handleChangeQuery( + normalizeToMatch((e.target as HTMLInputElement).value ?? "") + ); + }} + /> + + {/* show all Aliases that are associated with each Items */} + {entries.map((entry) => ( + + + {entry.name} + + + + + + ))} + + + + setOpenImportModal(false)} + /> + + ); +}; diff --git a/frontend/src/pages/CategoryEditPage.test.tsx b/frontend/src/pages/CategoryEditPage.test.tsx new file mode 100644 index 000000000..aa715c290 --- /dev/null +++ b/frontend/src/pages/CategoryEditPage.test.tsx @@ -0,0 +1,103 @@ +/** + * @jest-environment jsdom + */ + +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"; + +import { TestWrapperWithoutRoutes } from "TestWrapper"; +import { CategoryEditPage } from "pages/CategoryEditPage"; +import { editCategoryPath } from "routes/Routes"; + +const server = setupServer( + // getCategory + http.get("http://localhost/category/api/v2/1", () => { + return HttpResponse.json({ + id: 1, + name: "category1", + note: "サンプルカテゴリ", + priority: 1, + models: [ + { id: 1, name: "Entity1", is_public: true }, + { id: 2, name: "Entity2", is_public: true }, + ], + }); + }), + // getEntities + http.get("http://localhost/entity/api/v2/", () => { + return HttpResponse.json({ + count: 3, + next: null, + previous: null, + results: [ + { + id: 1, + name: "Entity1", + note: "", + isToplevel: false, + hasOngoingChanges: false, + attrs: [], + webhooks: [], + }, + { + id: 2, + name: "Entity2", + note: "", + isToplevel: false, + hasOngoingChanges: false, + attrs: [], + webhooks: [], + }, + { + id: 3, + name: "Entity3", + note: "", + isToplevel: false, + hasOngoingChanges: false, + attrs: [], + webhooks: [], + }, + ], + }); + }) +); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +test("should match snapshot", async () => { + Object.defineProperty(window, "django_context", { + value: { + user: { + is_superuser: false, + }, + }, + writable: false, + }); + + const router = createMemoryRouter( + [ + { + path: editCategoryPath(":categoryId"), + element: , + }, + ], + { + initialEntries: [editCategoryPath(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/CategoryListPage.test.tsx b/frontend/src/pages/CategoryListPage.test.tsx new file mode 100644 index 000000000..622a8480e --- /dev/null +++ b/frontend/src/pages/CategoryListPage.test.tsx @@ -0,0 +1,82 @@ +/** + * @jest-environment jsdom + */ + +import { 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"; + +import { TestWrapperWithoutRoutes } from "TestWrapper"; +import { CategoryListPage } from "pages/CategoryListPage"; +import { listCategoryPath } from "routes/Routes"; + +const server = setupServer( + // GET /category/api/v2/ + http.get("http://localhost/category/api/v2/", () => { + return HttpResponse.json({ + count: 2, + next: null, + previous: null, + results: [ + { + id: 1, + name: "category1", + note: "Sample Category 1", + priority: 1, + models: [ + { id: 1, name: "Entity1", is_public: true }, + { id: 2, name: "Entity2", is_public: true }, + ], + }, + { + id: 2, + name: "category2", + note: "Sample Category 2", + priority: 2, + models: [{ id: 3, name: "Entity3", is_public: true }], + }, + ], + }); + }) +); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("CategoryListPage", () => { + it("renders the category list", async () => { + // Create memory router + const router = createMemoryRouter( + [ + { + path: listCategoryPath(), + element: , + }, + ], + { + initialEntries: [listCategoryPath()], + } + ); + + // Render component + render( + + + + ); + + // Verify that the category list title is displayed (with specific element) + expect( + screen.getByRole("heading", { name: "カテゴリ一覧" }) + ).toBeInTheDocument(); + + // Wait for categories to be displayed + await waitFor(() => { + expect(screen.getByText("category1")).toBeInTheDocument(); + expect(screen.getByText("category2")).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/pages/CategoryListPage.tsx b/frontend/src/pages/CategoryListPage.tsx new file mode 100644 index 000000000..fed80c842 --- /dev/null +++ b/frontend/src/pages/CategoryListPage.tsx @@ -0,0 +1,24 @@ +import { Box, Typography } from "@mui/material"; +import React, { FC } from "react"; + +import { AironeLink } from "components"; +import { CategoryList } from "components/category/CategoryList"; +import { AironeBreadcrumbs } from "components/common/AironeBreadcrumbs"; +import { PageHeader } from "components/common/PageHeader"; +import { topPath } from "routes/Routes"; + +export const CategoryListPage: FC = () => { + return ( + + + + Top + + カテゴリ一覧 + + + + + + ); +}; diff --git a/frontend/src/pages/ListAliasEntryPage.test.tsx b/frontend/src/pages/ListAliasEntryPage.test.tsx new file mode 100644 index 000000000..8a231418d --- /dev/null +++ b/frontend/src/pages/ListAliasEntryPage.test.tsx @@ -0,0 +1,117 @@ +/** + * @jest-environment jsdom + */ + +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"; + +import { TestWrapperWithoutRoutes } from "TestWrapper"; +import { ListAliasEntryPage } from "pages/ListAliasEntryPage"; +import { listAliasPath } from "routes/Routes"; + +// モックロケーションを設定 +const mockLocation = { + pathname: "/ui/entities/1/alias", + search: "", + hash: "", + state: null, +}; + +// useLocationのモック +jest.mock("react-use", () => ({ + ...jest.requireActual("react-use"), + useLocation: () => mockLocation, +})); + +const server = setupServer( + // GET /entity/api/v2/:entityId/ + http.get("http://localhost/entity/api/v2/1/", () => { + return HttpResponse.json({ + id: 1, + name: "Entity1", + note: "テストエンティティ", + isToplevel: true, + hasOngoingChanges: false, + attrs: [], + webhooks: [], + }); + }), + + // GET /entry/api/v2/:entityId/ + http.get("http://localhost/entry/api/v2/1/", () => { + return HttpResponse.json({ + count: 2, + next: null, + previous: null, + results: [ + { + id: 1, + name: "Entry1", + schema: 1, + attrs: [], + aliases: [ + { id: 101, name: "Alias1-1" }, + { id: 102, name: "Alias1-2" }, + ], + }, + { + id: 2, + name: "Entry2", + schema: 1, + attrs: [], + aliases: [{ id: 201, name: "Alias2-1" }], + }, + ], + }); + }), + + // GET /entity/api/v2/:entityId/entries/ の未処理リクエストに対するモック + http.get("http://localhost/entity/api/v2/1/entries/", () => { + return HttpResponse.json({ + count: 0, + next: null, + previous: null, + results: [], + }); + }) +); + +beforeAll(() => server.listen({ onUnhandledRequest: "warn" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +test("should match snapshot", async () => { + Object.defineProperty(window, "django_context", { + value: { + user: { + is_superuser: false, + }, + }, + writable: false, + }); + + const router = createMemoryRouter( + [ + { + path: listAliasPath(":entityId"), + element: , + }, + ], + { + initialEntries: [listAliasPath(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/ListCategoryPage.test.tsx b/frontend/src/pages/ListCategoryPage.test.tsx new file mode 100644 index 000000000..6e5a9e84f --- /dev/null +++ b/frontend/src/pages/ListCategoryPage.test.tsx @@ -0,0 +1,80 @@ +/** + * @jest-environment jsdom + */ + +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"; + +import { TestWrapperWithoutRoutes } from "TestWrapper"; +import { ListCategoryPage } from "pages/ListCategoryPage"; +import { listCategoryPath } from "routes/Routes"; + +const server = setupServer( + // GET /category/api/v2/ + http.get("http://localhost/category/api/v2/", () => { + return HttpResponse.json({ + count: 2, + next: null, + previous: null, + results: [ + { + id: 1, + name: "category1", + note: "サンプルカテゴリ1", + priority: 1, + models: [ + { id: 1, name: "Entity1", is_public: true }, + { id: 2, name: "Entity2", is_public: true }, + ], + }, + { + id: 2, + name: "category2", + note: "サンプルカテゴリ2", + priority: 2, + models: [{ id: 3, name: "Entity3", is_public: true }], + }, + ], + }); + }) +); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +test("should match snapshot", async () => { + Object.defineProperty(window, "django_context", { + value: { + user: { + is_superuser: false, + }, + }, + writable: false, + }); + + const router = createMemoryRouter( + [ + { + path: listCategoryPath(), + element: , + }, + ], + { + initialEntries: [listCategoryPath()], + } + ); + 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/__snapshots__/CategoryEditPage.test.tsx.snap b/frontend/src/pages/__snapshots__/CategoryEditPage.test.tsx.snap new file mode 100644 index 000000000..ff21350cf --- /dev/null +++ b/frontend/src/pages/__snapshots__/CategoryEditPage.test.tsx.snap @@ -0,0 +1,966 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should match snapshot 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+
+
+ +
+
+
+
+
+
+
+ category1 +
+
+ カテゴリ編集 +
+
+
+ + +
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + +
+ 項目 + + 内容 +
+ カテゴリ名 + +
+
+ + +
+
+
+ 備考 + +
+
+ + +