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
5 changes: 5 additions & 0 deletions .changeset/stale-pants-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@premieroctet/next-admin": patch
---

feat: add grid layout
45 changes: 45 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,51 @@ This property determines how your data is displayed in the [list View](/docs/glo
</>
),
},
{
name: "layout",
type: "Object",
description: (
<>
layout config for the list page. Useful to configure a grid view.
</>
),
},
{
name: "layout.default",
type: "String",
description: (
<>
the default layout to use for the list view. It is optional. Either "table" or "grid". Defaults to "table".
</>
),
},
{
name: "layout.grid",
type: "Object",
description: (
<>
an object to configure each items of the grid layout
</>
),
},
{
name: "layout.grid.thumbnail",
type: "Function",
description: (
<>
A function that takes a <a href="#nextadmin-context">NextAdmin context</a> as parameter and returns a string representing the thumbnail URL. Can be a promise.
</>
),
},
{
name: "layout.grid.title",
type: "Function",
description: (
<>
A function that takes a <a href="#nextadmin-context">NextAdmin context</a> as parameter and returns a string representing the title of the item.
</>
),
},
]}
/>
<Callout type="info">
Expand Down
1 change: 0 additions & 1 deletion packages/database/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
"devDependencies": {
"@rslib/core": "^0.9.2",
"@types/node": "^22.14.1",
"glob": "^11.0.0",
"prisma": "catalog:prisma",
"tsconfig": "workspace:*",
"tsx": "^4.19.4",
Expand Down
4 changes: 1 addition & 3 deletions packages/database/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@ export default defineConfig({
},
source: {
entry: {
index: glob.sync("**/*.{ts,tsx}", {
ignore: ["prisma/**", "rslib.config.ts"],
}),
index: ["**/*.{ts,tsx}", "!prisma/**", "!rslib.config.ts"],
},
},
});
7 changes: 7 additions & 0 deletions packages/examples-common/options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ export const createOptions = (
display: ["id", "name", "email", "posts", "role", "birthDate"],
search: ["name", "email", "role"],
copy: ["email"],
layout: {
default: "table",
grid: {
title: ({ row }) => `${row?.name} (${row?.email})`,
thumbnail: ({ row }) => "https://picsum.photos/200",
},
},
filters: [
{
name: "is Admin",
Expand Down
2 changes: 1 addition & 1 deletion packages/next-admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.0.7",
"@radix-ui/react-visually-hidden": "^1.1.1",
"@rjsf/core": "6.0.0-beta.11",
Expand Down Expand Up @@ -230,7 +231,6 @@
"concurrently": "^8.0.1",
"eslint": "^7.32.0",
"eslint-config-custom": "workspace:*",
"glob": "^11.0.0",
"jsdom": "^26.1.0",
"lodash.debounce": "^4.0.8",
"next": "^15.3.1",
Expand Down
13 changes: 9 additions & 4 deletions packages/next-admin/rslib.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { RsbuildPluginAPI } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
import { defineConfig } from "@rslib/core";
import { glob } from "glob";
import { rmSync } from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -40,12 +39,18 @@ export default defineConfig({
to: path.resolve(basePath, "dist/theme.css"),
},
],
cleanDistPath: {
keep: [/schema\.cjs/, /schema\.mjs/],
},
},
source: {
entry: {
index: glob.sync("src/**/*.{ts,tsx}", {
ignore: ["**/tests/*", "**/*.test.{ts,tsx}", "**/generated/*"],
}),
index: [
"src/**/*.{ts,tsx}",
"!**/tests/*",
"!**/*.test.{ts,tsx}",
"!**/generated/*",
],
},
/**
* TODO: try to get rid of this at some point.
Expand Down
58 changes: 58 additions & 0 deletions packages/next-admin/src/components/DataGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { RowSelectionState } from "@tanstack/react-table";
import { AdminComponentProps, GridData, ModelName } from "../types";
import DataGridItem from "./DataGridItem";
import { Dispatch, SetStateAction } from "react";

type Props = {
data: GridData[];
resource: ModelName;
actions: AdminComponentProps["actions"];
onDelete: (id: string) => void;
canDelete: boolean;
selectedItems: RowSelectionState;
setSelectedItems: Dispatch<SetStateAction<RowSelectionState>>;
};

const DataGrid = ({
data,
resource,
actions,
onDelete,
canDelete,
selectedItems,
setSelectedItems,
}: Props) => {
const hasSelection = Object.keys(selectedItems).length > 0;

return (
<div className="grid grid-cols-[repeat(auto-fit,_minmax(150px,_1fr))] gap-4 sm:grid-cols-[repeat(auto-fit,_minmax(300px,_1fr))]">
{data.map((item) => {
return (
<DataGridItem
key={item.id}
item={item}
resource={resource}
actions={actions}
onDelete={onDelete}
canDelete={canDelete}
selectionVisible={hasSelection}
selected={selectedItems[item.id]}
onSelect={() =>
setSelectedItems((old) => {
const newSelected = { ...old };
if (newSelected[item.id]) {
delete newSelected[item.id];
} else {
newSelected[item.id] = true;
}
return newSelected;
})
}
/>
);
})}
</div>
);
};

export default DataGrid;
94 changes: 94 additions & 0 deletions packages/next-admin/src/components/DataGridItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { DocumentIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import { useState } from "react";
import { AdminComponentProps, GridData, ModelName } from "../types";
import Link from "./common/Link";
import { useConfig } from "../context/ConfigContext";
import { slugify } from "../utils/tools";
import ListActionsDropdown from "./ListActionsDropdown";
import { twMerge } from "tailwind-merge";
import Checkbox from "./radix/Checkbox";

type Props = {
item: GridData;
resource: ModelName;
actions: AdminComponentProps["actions"];
onDelete: (id: string) => void;
canDelete: boolean;
selectionVisible: boolean;
selected: boolean;
onSelect: (id: string) => void;
};

const DataGridItem = ({
item,
resource,
actions,
onDelete,
canDelete,
selectionVisible,
selected,
onSelect,
}: Props) => {
const [imageIsLoaded, setImageIsLoaded] = useState(false);
const { basePath } = useConfig();

return (
<Link
href={`${basePath}/${slugify(resource)}/${item.id}`}
className="group"
role="button"
>
<figure className="flex w-full flex-col items-center gap-2">
<div className="relative size-48 overflow-hidden rounded-md">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.thumbnail}
alt={item.title}
className={clsx("size-full object-contain", {
"opacity-0": !imageIsLoaded,
})}
onLoad={() => {
setImageIsLoaded(true);
}}
/>
{!imageIsLoaded && (
<div className="bg-nextadmin-menu-background dark:bg-dark-nextadmin-background-emphasis absolute inset-0 flex flex-col items-center justify-center rounded-md">
<DocumentIcon className="text-nextadmin-text-subtle dark:text-dark-nextadmin-text-subtle size-16" />
</div>
)}
<div className="absolute left-3 right-2 top-2 flex items-center justify-between">
<div
className={twMerge(
clsx("invisible", {
"group-hover:visible": !selectionVisible,
visible: selectionVisible,
})
)}
onClick={(e) => {
e.preventDefault();
onSelect(item.id as string);
}}
>
<Checkbox checked={selected} />
</div>
<div className="invisible group-hover:visible">
<ListActionsDropdown
actions={actions}
onDelete={() => onDelete(item.id as string)}
resource={resource}
canDelete={canDelete}
id={item.id}
/>
</div>
</div>
</div>
<legend className="text-nextadmin-text-default dark:text-dark-nextadmin-text-default line-clamp-1 break-all">
{item.title}
</legend>
</figure>
</Link>
);
};

export default DataGridItem;
41 changes: 41 additions & 0 deletions packages/next-admin/src/components/LayoutSwitch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Squares2X2Icon, TableCellsIcon } from "@heroicons/react/24/outline";
import { useRouterInternal } from "../hooks/useRouterInternal";
import { LayoutType } from "../types";
import { ToggleGroupItem, ToggleGroupRoot } from "./radix/ToggleGroup";

type Props = {
selectedLayout: LayoutType;
};

const LayoutSwitch = ({ selectedLayout }: Props) => {
const { router } = useRouterInternal();

return (
<div>
<ToggleGroupRoot
type="single"
value={selectedLayout}
onValueChange={(val) => {
if (!val) {
return;
}
router.setQuery(
{
layout: val,
},
true
);
}}
>
<ToggleGroupItem value="table">
<TableCellsIcon className="size-6" />
</ToggleGroupItem>
<ToggleGroupItem value="grid">
<Squares2X2Icon className="size-6" />
</ToggleGroupItem>
</ToggleGroupRoot>
</div>
);
};

export default LayoutSwitch;
Loading
Loading