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) {