Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/docs/pages/docs/api/model-configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</>
),
},
]}
/>
<Callout type="info">
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@
"module": "ESNext",
"skipLibCheck": true
},
"include": ["/**/*.ts"],
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}
235 changes: 235 additions & 0 deletions packages/next-admin/src/components/Cell.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<ConfigProvider
basePath="/admin"
apiBasePath="/api/admin"
nextAdminContext={{ locale: "en" }}
>
{children}
</ConfigProvider>
);

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: <span data-testid="custom-bool">Active</span>,
};

const formatter = (value: boolean) => (
<span data-testid="custom-bool">
{value ? "Active" : "Inactive"}
</span>
);

const { container } = render(
<TestWrapper>
<Cell cell={cell} formatter={formatter} getRawData={() => true} />
</TestWrapper>
);

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: (
<span data-testid="custom-bool">Inactive</span>
),
};

const { container } = render(
<TestWrapper>
<Cell cell={cell} />
</TestWrapper>
);

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(
<TestWrapper>
<Cell cell={cell} />
</TestWrapper>
);

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(
<TestWrapper>
<Cell cell={cell} />
</TestWrapper>
);

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: <strong data-testid="custom-str">Test</strong>,
};

const { container } = render(
<TestWrapper>
<Cell cell={cell} />
</TestWrapper>
);

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: <em data-testid="custom-str">Hello World</em>,
};

const { container } = render(
<TestWrapper>
<Cell cell={cell} />
</TestWrapper>
);

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: (
<span data-testid="custom-num">Forty-two</span>
),
};

const { container } = render(
<TestWrapper>
<Cell cell={cell} />
</TestWrapper>
);

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: <span data-testid="custom-num">100%</span>,
};

const { container } = render(
<TestWrapper>
<Cell cell={cell} />
</TestWrapper>
);

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: (
<time data-testid="custom-date">January 1, 2024</time>
),
};

const { container } = render(
<TestWrapper>
<Cell cell={cell} />
</TestWrapper>
);

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: (
<span data-testid="custom-date">New Year&apos;s Eve</span>
),
};

const { container } = render(
<TestWrapper>
<Cell cell={cell} />
</TestWrapper>
);

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: <span data-testid="copyable-bool">Yes</span>,
};

const { container } = render(
<TestWrapper>
<Cell cell={cell} copyable={true} />
</TestWrapper>
);

expect(container.textContent).toContain("Yes");
expect(container.querySelector('[data-testid="copyable-bool"]')).toBeTruthy();
});
});
});
101 changes: 101 additions & 0 deletions packages/next-admin/src/components/List.test.tsx
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
13 changes: 9 additions & 4 deletions packages/next-admin/src/components/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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;

Expand Down
Loading