diff --git a/apps/docs/pages/docs/api/model-configuration.mdx b/apps/docs/pages/docs/api/model-configuration.mdx index b1e32e86..b3dbd12b 100644 --- a/apps/docs/pages/docs/api/model-configuration.mdx +++ b/apps/docs/pages/docs/api/model-configuration.mdx @@ -233,6 +233,18 @@ This property determines how your data is displayed in the [list View](/docs/glo ), }, + { + name: "maxRows", + type: "Number", + description: ( + <> + the maximum number of items that can be displayed per page. When set, + the items per page dropdown will only show options up to this value, and + server-side enforcement prevents fetching more rows than this limit. + Useful for memory-constrained environments. + + ), + }, ]} /> 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.test.ts b/packages/next-admin/src/utils/prisma.test.ts index cbfb6f88..72b1d23d 100644 --- a/packages/next-admin/src/utils/prisma.test.ts +++ b/packages/next-admin/src/utils/prisma.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest"; import { NextAdminOptions } from "../types"; import "../tests/singleton"; import { createWherePredicate } from "./prisma"; +import { ITEMS_PER_PAGE } from "../config"; const options: NextAdminOptions = { model: { @@ -60,4 +61,62 @@ describe("Prisma utils", () => { ], }); }); + + describe("maxRows server-side enforcement logic", () => { + test("should cap itemsPerPage when it exceeds maxRows", () => { + const modelMaxRows = 50; + let itemsPerPage = 200; + + if (modelMaxRows && itemsPerPage > modelMaxRows) { + itemsPerPage = modelMaxRows; + } + + expect(itemsPerPage).toBe(50); + }); + + test("should not modify itemsPerPage when it is below maxRows", () => { + const modelMaxRows = 50; + let itemsPerPage = 25; + + if (modelMaxRows && itemsPerPage > modelMaxRows) { + itemsPerPage = modelMaxRows; + } + + expect(itemsPerPage).toBe(25); + }); + + test("should not modify itemsPerPage when maxRows is not set", () => { + const modelMaxRows = undefined; + let itemsPerPage = 100; + + if (modelMaxRows && itemsPerPage > modelMaxRows) { + itemsPerPage = modelMaxRows; + } + + expect(itemsPerPage).toBe(100); + }); + + test("should use defaultListSize and cap it when it exceeds maxRows", () => { + const modelDefaultListSize = 75; + const modelMaxRows = 50; + let itemsPerPage = modelDefaultListSize || ITEMS_PER_PAGE; + + if (modelMaxRows && itemsPerPage > modelMaxRows) { + itemsPerPage = modelMaxRows; + } + + expect(itemsPerPage).toBe(50); + }); + + test("should handle maxRows equal to itemsPerPage", () => { + const modelMaxRows = 50; + let itemsPerPage = 50; + + if (modelMaxRows && itemsPerPage > modelMaxRows) { + itemsPerPage = modelMaxRows; + } + + expect(itemsPerPage).toBe(50); + }); + }); }); diff --git a/packages/next-admin/src/utils/prisma.ts b/packages/next-admin/src/utils/prisma.ts index f95291c8..aa3b5c47 100644 --- a/packages/next-admin/src/utils/prisma.ts +++ b/packages/next-admin/src/utils/prisma.ts @@ -302,11 +302,17 @@ const preparePrismaListRequest = async ( : (JSON.parse(searchParams.get("filters")) as string[]); } catch {} const page = Number(searchParams.get("page")) || 1; - const itemsPerPage = + const modelMaxRows = options?.model?.[resource]?.list?.maxRows; + let itemsPerPage = Number(searchParams.get("itemsPerPage")) || options?.model?.[resource]?.list?.defaultListSize || ITEMS_PER_PAGE; + // Enforce maxRows limit if set + if (modelMaxRows && itemsPerPage > modelMaxRows) { + itemsPerPage = modelMaxRows; + } + const fieldSort = options?.model?.[resource]?.list?.defaultSort; const filters = await mapModelFilters( @@ -627,10 +633,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; }