diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json
index 8a606707..ccfcd319 100644
--- a/packages/cli/tsconfig.json
+++ b/packages/cli/tsconfig.json
@@ -10,6 +10,6 @@
"module": "ESNext",
"skipLibCheck": true
},
- "include": ["/**/*.ts"],
+ "include": ["src/**/*.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..6faabd72
--- /dev/null
+++ b/packages/next-admin/src/components/Cell.test.tsx
@@ -0,0 +1,235 @@
+import { describe, it, expect, afterEach } from "vitest";
+import React from "react";
+import { render, cleanup } from "@testing-library/react";
+import Cell from "./Cell";
+import { ConfigProvider } from "../context/ConfigContext";
+import type { ListDataFieldValue } from "../types";
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+describe("Cell component", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ describe("boolean fields with formatters", () => {
+ it("should render JSX from boolean formatter", () => {
+ const cell: ListDataFieldValue = {
+ type: "scalar",
+ value: true,
+ __nextadmin_formatted: Active,
+ };
+
+ const formatter = (value: boolean) => (
+
+ {value ? "Active" : "Inactive"}
+
+ );
+
+ const { container } = render(
+
+ | true} />
+ |
+ );
+
+ expect(container.textContent).toContain("Active");
+ expect(container.querySelector('[data-testid="custom-bool"]')).toBeTruthy();
+ });
+
+ it("should not display [object Object] for boolean with JSX formatter", () => {
+ const cell: ListDataFieldValue = {
+ type: "scalar",
+ value: false,
+ __nextadmin_formatted: (
+ Inactive
+ ),
+ };
+
+ const { container } = render(
+
+ |
+
+ );
+
+ expect(container.textContent).not.toContain("[object Object]");
+ expect(container.textContent).toContain("Inactive");
+ expect(container.querySelector('[data-testid="custom-bool"]')).toBeTruthy();
+ });
+
+ it("should render default boolean display when formatter returns string", () => {
+ const cell: ListDataFieldValue = {
+ type: "scalar",
+ value: true,
+ __nextadmin_formatted: "Yes",
+ };
+
+ const { container } = render(
+
+ |
+
+ );
+
+ expect(container.textContent).toContain("Yes");
+ });
+
+ it("should render boolean toString when no formatter", () => {
+ const cell: ListDataFieldValue = {
+ type: "scalar",
+ value: true,
+ __nextadmin_formatted: "true",
+ };
+
+ const { container } = render(
+
+ |
+
+ );
+
+ expect(container.textContent).toContain("true");
+ });
+ });
+
+ describe("string fields with formatters", () => {
+ it("should render JSX from string formatter", () => {
+ const cell: ListDataFieldValue = {
+ type: "scalar",
+ value: "test",
+ __nextadmin_formatted: Test,
+ };
+
+ const { container } = render(
+
+ |
+
+ );
+
+ expect(container.textContent).toContain("Test");
+ expect(container.querySelector('[data-testid="custom-str"]')).toBeTruthy();
+ });
+
+ it("should not display [object Object] for string with JSX formatter", () => {
+ const cell: ListDataFieldValue = {
+ type: "scalar",
+ value: "hello",
+ __nextadmin_formatted: Hello World,
+ };
+
+ const { container } = render(
+
+ |
+
+ );
+
+ expect(container.textContent).not.toContain("[object Object]");
+ expect(container.textContent).toContain("Hello World");
+ expect(container.querySelector('[data-testid="custom-str"]')).toBeTruthy();
+ });
+ });
+
+ describe("number fields with formatters", () => {
+ it("should render JSX from number formatter", () => {
+ const cell: ListDataFieldValue = {
+ type: "scalar",
+ value: 42,
+ __nextadmin_formatted: (
+ Forty-two
+ ),
+ };
+
+ const { container } = render(
+
+ |
+
+ );
+
+ expect(container.textContent).toContain("Forty-two");
+ expect(container.querySelector('[data-testid="custom-num"]')).toBeTruthy();
+ });
+
+ it("should not display [object Object] for number with JSX formatter", () => {
+ const cell: ListDataFieldValue = {
+ type: "scalar",
+ value: 100,
+ __nextadmin_formatted: 100%,
+ };
+
+ const { container } = render(
+
+ |
+
+ );
+
+ expect(container.textContent).not.toContain("[object Object]");
+ expect(container.textContent).toContain("100%");
+ expect(container.querySelector('[data-testid="custom-num"]')).toBeTruthy();
+ });
+ });
+
+ describe("date fields with formatters", () => {
+ it("should render JSX from date formatter", () => {
+ const cell: ListDataFieldValue = {
+ type: "date",
+ value: new Date("2024-01-01"),
+ __nextadmin_formatted: (
+
+ ),
+ };
+
+ const { container } = render(
+
+ |
+
+ );
+
+ expect(container.textContent).toContain("January 1, 2024");
+ expect(container.querySelector('[data-testid="custom-date"]')).toBeTruthy();
+ });
+
+ it("should not display [object Object] for date with JSX formatter", () => {
+ const cell: ListDataFieldValue = {
+ type: "date",
+ value: new Date("2024-12-31"),
+ __nextadmin_formatted: (
+ New Year's Eve
+ ),
+ };
+
+ const { container } = render(
+
+ |
+
+ );
+
+ expect(container.textContent).not.toContain("[object Object]");
+ expect(container.textContent).toContain("New Year");
+ expect(container.querySelector('[data-testid="custom-date"]')).toBeTruthy();
+ });
+ });
+
+ describe("copyable cells", () => {
+ it("should render copyable boolean field with JSX formatter", () => {
+ const cell: ListDataFieldValue = {
+ type: "scalar",
+ value: true,
+ __nextadmin_formatted: Yes,
+ };
+
+ const { container } = render(
+
+ |
+
+ );
+
+ expect(container.textContent).toContain("Yes");
+ expect(container.querySelector('[data-testid="copyable-bool"]')).toBeTruthy();
+ });
+ });
+});
diff --git a/packages/next-admin/src/components/List.test.tsx b/packages/next-admin/src/components/List.test.tsx
new file mode 100644
index 00000000..f46790a1
--- /dev/null
+++ b/packages/next-admin/src/components/List.test.tsx
@@ -0,0 +1,101 @@
+import { describe, expect, it } from "vitest";
+
+describe("List component maxRows filtering", () => {
+ const itemsPerPageSizes = [10, 25, 50, 100];
+
+ it("should filter sizes when maxRows is set", () => {
+ const modelDefaultListSize = undefined;
+ const modelMaxRows = 50;
+
+ let sizes = modelDefaultListSize
+ ? [...itemsPerPageSizes, modelDefaultListSize].sort((a, b) => a - b)
+ : itemsPerPageSizes;
+
+ if (modelMaxRows) {
+ sizes = sizes.filter((size) => size <= modelMaxRows);
+ }
+
+ expect(sizes).toEqual([10, 25, 50]);
+ expect(sizes).not.toContain(100);
+ });
+
+ it("should not filter sizes when maxRows is not set", () => {
+ const modelDefaultListSize = undefined;
+ const modelMaxRows = undefined;
+
+ let sizes = modelDefaultListSize
+ ? [...itemsPerPageSizes, modelDefaultListSize].sort((a, b) => a - b)
+ : itemsPerPageSizes;
+
+ if (modelMaxRows) {
+ sizes = sizes.filter((size) => size <= modelMaxRows);
+ }
+
+ expect(sizes).toEqual([10, 25, 50, 100]);
+ });
+
+ it("should include defaultListSize and filter by maxRows", () => {
+ const modelDefaultListSize = 75;
+ const modelMaxRows = 50;
+
+ let sizes = modelDefaultListSize
+ ? [...itemsPerPageSizes, modelDefaultListSize].sort((a, b) => a - b)
+ : itemsPerPageSizes;
+
+ if (modelMaxRows) {
+ sizes = sizes.filter((size) => size <= modelMaxRows);
+ }
+
+ // 75 should be filtered out because it exceeds maxRows of 50
+ expect(sizes).toEqual([10, 25, 50]);
+ expect(sizes).not.toContain(75);
+ expect(sizes).not.toContain(100);
+ });
+
+ it("should include defaultListSize when it is below maxRows", () => {
+ const modelDefaultListSize = 30;
+ const modelMaxRows = 50;
+
+ let sizes = modelDefaultListSize
+ ? [...itemsPerPageSizes, modelDefaultListSize].sort((a, b) => a - b)
+ : itemsPerPageSizes;
+
+ if (modelMaxRows) {
+ sizes = sizes.filter((size) => size <= modelMaxRows);
+ }
+
+ expect(sizes).toEqual([10, 25, 30, 50]);
+ expect(sizes).not.toContain(100);
+ });
+
+ it("should handle maxRows equal to an existing size", () => {
+ const modelDefaultListSize = undefined;
+ const modelMaxRows = 25;
+
+ let sizes = modelDefaultListSize
+ ? [...itemsPerPageSizes, modelDefaultListSize].sort((a, b) => a - b)
+ : itemsPerPageSizes;
+
+ if (modelMaxRows) {
+ sizes = sizes.filter((size) => size <= modelMaxRows);
+ }
+
+ expect(sizes).toEqual([10, 25]);
+ });
+
+ it("should handle maxRows smaller than all default sizes", () => {
+ const modelDefaultListSize = undefined;
+ const modelMaxRows = 5;
+
+ let sizes = modelDefaultListSize
+ ? [...itemsPerPageSizes, modelDefaultListSize].sort((a, b) => a - b)
+ : itemsPerPageSizes;
+
+ if (modelMaxRows) {
+ sizes = sizes.filter((size) => size <= modelMaxRows);
+ }
+
+ // All sizes should be filtered out since they all exceed maxRows
+ expect(sizes).toEqual([]);
+ });
+});
diff --git a/packages/next-admin/src/components/List.tsx b/packages/next-admin/src/components/List.tsx
index eb10f237..e681dfca 100644
--- a/packages/next-admin/src/components/List.tsx
+++ b/packages/next-admin/src/components/List.tsx
@@ -89,6 +89,7 @@ function List({
const pageIndex = typeof query.page === "string" ? Number(query.page) - 1 : 0;
const modelDefaultListSize =
options?.model?.[resource]?.list?.defaultListSize;
+ const modelMaxRows = options?.model?.[resource]?.list?.maxRows;
const pageSize =
Number(query.itemsPerPage) || modelDefaultListSize || ITEMS_PER_PAGE;
const modelOptions = options?.["model"]?.[resource];
@@ -111,12 +112,16 @@ function List({
});
const allListSizes = useMemo(() => {
- if (modelDefaultListSize) {
- return [...itemsPerPageSizes, modelDefaultListSize].sort((a, b) => a - b);
+ let sizes = modelDefaultListSize
+ ? [...itemsPerPageSizes, modelDefaultListSize].sort((a, b) => a - b)
+ : itemsPerPageSizes;
+
+ if (modelMaxRows) {
+ sizes = sizes.filter((size) => size <= modelMaxRows);
}
- return itemsPerPageSizes;
- }, [modelDefaultListSize]);
+ return sizes;
+ }, [modelDefaultListSize, modelMaxRows]);
let onSearchChange;
diff --git a/packages/next-admin/src/types.ts b/packages/next-admin/src/types.ts
index a6a5c033..b8f98caa 100644
--- a/packages/next-admin/src/types.ts
+++ b/packages/next-admin/src/types.ts
@@ -501,6 +501,11 @@ export type ListOptions = {
* an optional number indicating the default amount of items in the list
*/
defaultListSize?: number;
+ /**
+ * an optional number indicating the maximum amount of items that can be displayed per page
+ * When set, the items per page dropdown will only show options up to this value
+ */
+ maxRows?: number;
};
export type RelationshipsRawData = Record;
diff --git a/packages/next-admin/src/utils/prisma.ts b/packages/next-admin/src/utils/prisma.ts
index f95291c8..68bbb05b 100644
--- a/packages/next-admin/src/utils/prisma.ts
+++ b/packages/next-admin/src/utils/prisma.ts
@@ -627,10 +627,11 @@ export const mapDataList = ({
item[key].__nextadmin_formatted = itemValue;
} else if (isScalar(item[key]) && item[key] !== null) {
+ const originalValue = item[key];
item[key] = {
type: "scalar",
- value: item[key],
- __nextadmin_formatted: item[key].toString(),
+ value: originalValue,
+ __nextadmin_formatted: originalValue.toString(),
};
itemValue = item[key].value;
}