diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 8a606707..d0bb5f87 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -10,6 +10,6 @@ "module": "ESNext", "skipLibCheck": true }, - "include": ["/**/*.ts"], + "include": ["**/*.ts"], "exclude": ["dist", "node_modules"] } diff --git a/packages/next-admin/src/components/Cell.test.tsx b/packages/next-admin/src/components/Cell.test.tsx new file mode 100644 index 00000000..44725095 --- /dev/null +++ b/packages/next-admin/src/components/Cell.test.tsx @@ -0,0 +1,277 @@ +import { describe, it, expect, vi } from "vitest"; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import Cell from "./Cell"; +import { ConfigProvider } from "../context/ConfigContext"; +import { ListDataFieldValue } from "../types"; + +const mockConfigValue = { + basePath: "/admin", + apiBasePath: "/api/admin", + isAppDir: true, + options: {}, + nextAdminContext: { locale: "en" }, +}; + +const CellWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe("Cell component", () => { + describe("boolean values", () => { + it("should render default boolean formatting for true", () => { + const cell: ListDataFieldValue = { + type: "scalar", + value: true, + __nextadmin_formatted: "true", + }; + + const { container } = render( + + + + ); + + expect(container.textContent).toBe("true"); + }); + + it("should render default boolean formatting for false", () => { + const cell: ListDataFieldValue = { + type: "scalar", + value: false, + __nextadmin_formatted: "false", + }; + + const { container } = render( + + + + ); + + expect(container.textContent).toBe("false"); + }); + + it("should render string formatter result for boolean", () => { + const cell: ListDataFieldValue = { + type: "scalar", + value: true, + __nextadmin_formatted: "Yes", + }; + + const { container } = render( + + + + ); + + expect(container.textContent).toBe("Yes"); + }); + + it("should render ReactNode formatter result for boolean", () => { + const cell: ListDataFieldValue = { + type: "scalar", + value: true, + __nextadmin_formatted:
Custom
, + }; + + render( + + + + ); + + const element = screen.getByTestId("custom-format"); + expect(element).toBeDefined(); + expect(element.textContent).toBe("Custom"); + }); + + it("should render complex ReactNode formatter for boolean", () => { + const cell: ListDataFieldValue = { + type: "scalar", + value: false, + __nextadmin_formatted: ( + Unpublished + ), + }; + + render( + + + + ); + + const element = screen.getByTestId("bool-strong"); + expect(element).toBeDefined(); + expect(element.tagName).toBe("STRONG"); + expect(element.textContent).toBe("Unpublished"); + }); + + it("should call formatter function for boolean and render ReactNode result", () => { + const cell: ListDataFieldValue = { + type: "scalar", + value: true, + __nextadmin_formatted: "true", + }; + + const formatter = vi.fn((value: boolean) => ( + Published + )); + + render( + + true} + /> + + ); + + expect(formatter).toHaveBeenCalled(); + const element = screen.getByTestId("formatted-bool"); + expect(element).toBeDefined(); + expect(element.textContent).toBe("Published"); + }); + + it("should call formatter function for boolean false and render result", () => { + const cell: ListDataFieldValue = { + type: "scalar", + value: false, + __nextadmin_formatted: "false", + }; + + const formatter = vi.fn((value: boolean) => ( + Not Published + )); + + render( + + false} + /> + + ); + + expect(formatter).toHaveBeenCalled(); + const element = screen.getByTestId("unpublished"); + expect(element).toBeDefined(); + expect(element.textContent).toBe("Not Published"); + }); + + it("should call formatter function that returns string for boolean", () => { + const cell: ListDataFieldValue = { + type: "scalar", + value: true, + __nextadmin_formatted: "true", + }; + + const formatter = vi.fn((value: boolean) => (value ? "Yes" : "No")); + + const { container } = render( + + true} + /> + + ); + + expect(formatter).toHaveBeenCalled(); + expect(container.textContent).toBe("Yes"); + }); + }); + + describe("other scalar types", () => { + it("should render string values", () => { + const cell: ListDataFieldValue = { + type: "scalar", + value: "test string", + __nextadmin_formatted: "test string", + }; + + const { container } = render( + + + + ); + + expect(container.textContent).toBe("test string"); + }); + + it("should render number values", () => { + const cell: ListDataFieldValue = { + type: "scalar", + value: 42, + __nextadmin_formatted: "42", + }; + + const { container } = render( + + + + ); + + expect(container.textContent).toBe("42"); + }); + }); + + describe("count type", () => { + it("should render count values", () => { + const cell: ListDataFieldValue = { + type: "count", + value: 5, + __nextadmin_formatted: "5", + }; + + const { container } = render( + + + + ); + + expect(container.textContent).toBe("5"); + }); + }); + + describe("date type", () => { + it("should render date values", () => { + const date = new Date("2024-01-01"); + const cell: ListDataFieldValue = { + type: "date", + value: date, + __nextadmin_formatted: "2024-01-01", + }; + + const { container } = render( + + + + ); + + expect(container.textContent).toContain("2024-01-01"); + }); + }); + + describe("ReactElement at top level", () => { + it("should render ReactElement directly when provided as __nextadmin_formatted", () => { + const cell: ListDataFieldValue = { + type: "scalar", + value: "test", + __nextadmin_formatted: Top Level, + }; + + render( + + + + ); + + const element = screen.getByTestId("top-level"); + expect(element).toBeDefined(); + expect(element.textContent).toBe("Top Level"); + }); + }); +}); diff --git a/packages/next-admin/src/components/Cell.tsx b/packages/next-admin/src/components/Cell.tsx index b2dc0c1f..e641356b 100644 --- a/packages/next-admin/src/components/Cell.tsx +++ b/packages/next-admin/src/components/Cell.tsx @@ -89,6 +89,10 @@ export default function Cell({ cell, formatter, copyable, getRawData }: Props) { ); } else if (cell.type === "scalar" && typeof cell.value === "boolean") { + // Check if cellValue is a React element (from a custom formatter) + if (React.isValidElement(cellValue)) { + return renderCustomElement(cellValue); + } return (
({ asc: "asc", desc: "desc", }, + PostScalarFieldEnum: { + id: "id", + title: "title", + content: "content", + published: "published", + authorId: "authorId", + order: "order", + rate: "rate", + tags: "tags", + images: "images", + }, + UserScalarFieldEnum: { + id: "id", + name: "name", + email: "email", + role: "role", + birthDate: "birthDate", + hashedPassword: "hashedPassword", + avatar: "avatar", + createdAt: "createdAt", + updatedAt: "updatedAt", + metadata: "metadata", + }, + CategoryScalarFieldEnum: { + id: "id", + name: "name", + }, }, default: mockDeep(), })); diff --git a/packages/next-admin/src/utils/prisma.test.ts b/packages/next-admin/src/utils/prisma.test.ts index cbfb6f88..b04f5eb2 100644 --- a/packages/next-admin/src/utils/prisma.test.ts +++ b/packages/next-admin/src/utils/prisma.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; +import { mockReset } from "vitest-mock-extended"; import { NextAdminOptions } from "../types"; -import "../tests/singleton"; -import { createWherePredicate } from "./prisma"; +import { prismaMock } from "../tests/singleton"; +import { createWherePredicate, getMappedDataList } from "./prisma"; const options: NextAdminOptions = { model: { @@ -14,6 +15,10 @@ const options: NextAdminOptions = { }; describe("Prisma utils", () => { + afterEach(() => { + mockReset(prismaMock); + }); + test("createWherePredicate", () => { expect( createWherePredicate({ @@ -60,4 +65,178 @@ describe("Prisma utils", () => { ], }); }); + + test("getMappedDataList with sort parameters", async () => { + const postData = [ + { + id: 1, + title: "Post 1", + content: "Content 1", + published: true, + authorId: 1, + }, + { + id: 2, + title: "Post 2", + content: "Content 2", + published: false, + authorId: 2, + }, + ]; + + prismaMock.post.findMany.mockResolvedValue(postData); + prismaMock.post.count.mockResolvedValue(2); + + const searchParams = new URLSearchParams({ + sortColumn: "title", + sortDirection: "asc", + }); + + const result = await getMappedDataList({ + prisma: prismaMock, + resource: "Post", + options, + searchParams, + context: {}, + appDir: false, + }); + + expect(result.data).toBeDefined(); + expect(result.total).toBe(2); + expect(result.error).toBeNull(); + + // Verify that findMany was called with orderBy parameter + expect(prismaMock.post.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: expect.objectContaining({ title: "asc" }), + }) + ); + }); + + test("getMappedDataList with desc sort direction", async () => { + const postData = [ + { + id: 2, + title: "Post 2", + content: "Content 2", + published: false, + authorId: 2, + }, + { + id: 1, + title: "Post 1", + content: "Content 1", + published: true, + authorId: 1, + }, + ]; + + prismaMock.post.findMany.mockResolvedValue(postData); + prismaMock.post.count.mockResolvedValue(2); + + const searchParams = new URLSearchParams({ + sortColumn: "title", + sortDirection: "desc", + }); + + const result = await getMappedDataList({ + prisma: prismaMock, + resource: "Post", + options, + searchParams, + context: {}, + appDir: false, + }); + + expect(result.data).toBeDefined(); + expect(result.total).toBe(2); + expect(result.error).toBeNull(); + + // Verify that findMany was called with orderBy parameter + expect(prismaMock.post.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: expect.objectContaining({ title: "desc" }), + }) + ); + }); + + test("getMappedDataList with invalid sort direction falls back to default", async () => { + const postData = [ + { + id: 1, + title: "Post 1", + content: "Content 1", + published: true, + authorId: 1, + }, + ]; + + prismaMock.post.findMany.mockResolvedValue(postData); + prismaMock.post.count.mockResolvedValue(1); + + const searchParams = new URLSearchParams({ + sortColumn: "title", + sortDirection: "invalid" as any, + }); + + const result = await getMappedDataList({ + prisma: prismaMock, + resource: "Post", + options, + searchParams, + context: {}, + appDir: false, + }); + + expect(result.data).toBeDefined(); + expect(result.total).toBe(1); + expect(result.error).toBeNull(); + + // Should fallback to sorting by id when invalid sort direction is provided + expect(prismaMock.post.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: expect.objectContaining({ id: "asc" }), + }) + ); + }); + + test("getMappedDataList with undefined sort direction falls back to default", async () => { + const postData = [ + { + id: 1, + title: "Post 1", + content: "Content 1", + published: true, + authorId: 1, + }, + ]; + + prismaMock.post.findMany.mockResolvedValue(postData); + prismaMock.post.count.mockResolvedValue(1); + + // Simulate clicking on a column header without sortDirection set yet + const searchParams = new URLSearchParams({ + sortColumn: "title", + }); + + const result = await getMappedDataList({ + prisma: prismaMock, + resource: "Post", + options, + searchParams, + context: {}, + appDir: false, + }); + + expect(result.data).toBeDefined(); + expect(result.total).toBe(1); + expect(result.error).toBeNull(); + + // Should fallback to sorting by id when sortDirection is undefined + expect(prismaMock.post.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: expect.objectContaining({ id: "asc" }), + }) + ); + }); }); diff --git a/packages/next-admin/src/utils/prisma.ts b/packages/next-admin/src/utils/prisma.ts index f95291c8..1688b540 100644 --- a/packages/next-admin/src/utils/prisma.ts +++ b/packages/next-admin/src/utils/prisma.ts @@ -337,8 +337,16 @@ const preparePrismaListRequest = async ( modelProperties[field as keyof typeof modelProperties]; const modelFieldNextAdminData = modelFieldSortParam?.__nextadmin; - if (direction in Prisma.SortOrder) { - if (field in Prisma[`${capitalize(resource)}ScalarFieldEnum`]) { + // Validate sort direction - check if it's a valid Prisma.SortOrder value + const isValidSortDirection = + direction && + (Prisma?.SortOrder && direction in Prisma.SortOrder || + direction === "asc" || + direction === "desc"); + + if (isValidSortDirection) { + const scalarFieldEnum = Prisma?.[`${capitalize(resource)}ScalarFieldEnum`]; + if (scalarFieldEnum && field in scalarFieldEnum) { return { [field]: direction }; } else if (modelFieldNextAdminData?.kind === "object") { if (modelFieldNextAdminData.isList) {