diff --git a/.changeset/gold-baboons-rhyme.md b/.changeset/gold-baboons-rhyme.md
new file mode 100644
index 00000000..a1a646ab
--- /dev/null
+++ b/.changeset/gold-baboons-rhyme.md
@@ -0,0 +1,5 @@
+---
+"@premieroctet/next-admin": minor
+---
+
+feat: allow multifile upload (#519)
diff --git a/apps/docs/pages/docs/api/model-configuration.mdx b/apps/docs/pages/docs/api/model-configuration.mdx
index 25a62741..65983f1d 100644
--- a/apps/docs/pages/docs/api/model-configuration.mdx
+++ b/apps/docs/pages/docs/api/model-configuration.mdx
@@ -469,6 +469,15 @@ When you define a field, use the field's name as the key and the following objec
description:
"an optional string displayed in the input field as an error message in case of a failure during the upload handler",
},
+ {
+ name: "handler.deleteFile",
+ type: "Function",
+ description: (
+ <>
+ an async function that is used to remove a file from a remote provider. Takes the file URI as an argument.
+ >
+ )
+ },
{
name: "optionFormatter",
type: "Function",
@@ -671,6 +680,25 @@ The `actions` property is an array of objects that allows you to define a set of
]}
/>
+## `middlewares` property
+
+The `middlewares` property is an object of functions executed either before a record's update or deletion, where you can control if the deletion and update should happen or not. It can have the following properties:
+
+
+
## NextAdmin Context
The `NextAdmin` context is an object containing the following properties:
@@ -740,7 +768,7 @@ export const options: NextAdminOptions = {
model: {
User: {
/**
- ...some configuration
+ ...some configuration
**/
edit: {
display: [
diff --git a/apps/example/options.tsx b/apps/example/options.tsx
index 08b677a2..042c5f48 100644
--- a/apps/example/options.tsx
+++ b/apps/example/options.tsx
@@ -1,3 +1,4 @@
+import { faker } from "@faker-js/faker";
import AddTagDialog from "@/components/PostAddTagDialogContent";
import UserDetailsDialog from "@/components/UserDetailsDialogContent";
import { NextAdminOptions } from "@premieroctet/next-admin";
@@ -121,7 +122,7 @@ export const options: NextAdminOptions = {
* Make sure to return a string.
*/
upload: async (buffer, infos, context) => {
- return "https://raw.githubusercontent.com/premieroctet/next-admin/33fcd755a34f1ec5ad53ca8e293029528af814ca/apps/example/public/assets/logo.svg";
+ return faker.image.url({ width: 200, height: 200 });
},
},
},
@@ -293,6 +294,14 @@ export const options: NextAdminOptions = {
orderField: "order",
relationshipSearchField: "category",
},
+ images: {
+ format: "file",
+ handler: {
+ upload: async (buffer, infos, context) => {
+ return faker.image.url({ width: 200, height: 200 });
+ },
+ },
+ },
},
display: [
"id",
@@ -303,6 +312,7 @@ export const options: NextAdminOptions = {
"author",
"rate",
"tags",
+ "images",
],
hooks: {
async beforeDb(data, mode, request) {
diff --git a/apps/example/package.json b/apps/example/package.json
index 374e3565..d6b4274e 100644
--- a/apps/example/package.json
+++ b/apps/example/package.json
@@ -20,14 +20,15 @@
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
},
"dependencies": {
+ "@faker-js/faker": "^9.4.0",
"@heroicons/react": "^2.0.18",
"@picocss/pico": "^1.5.7",
"@premieroctet/next-admin": "workspace:*",
"@premieroctet/next-admin-generator-prisma": "workspace:*",
- "next-intl": "^3.3.2",
"@prisma/client": "5.14.0",
"@tremor/react": "^3.2.2",
"next": "^15.1.0",
+ "next-intl": "^3.3.2",
"next-superjson": "^1.0.7",
"next-superjson-plugin": "^0.6.3",
"react": "^19.0.0",
diff --git a/apps/example/pageRouterOptions.tsx b/apps/example/pageRouterOptions.tsx
index 00ddce3c..cd00a51c 100644
--- a/apps/example/pageRouterOptions.tsx
+++ b/apps/example/pageRouterOptions.tsx
@@ -1,3 +1,4 @@
+import { faker } from "@faker-js/faker";
import { NextAdminOptions } from "@premieroctet/next-admin";
import DatePicker from "./components/DatePicker";
import PasswordInput from "./components/PasswordInput";
@@ -86,7 +87,7 @@ export const options: NextAdminOptions = {
* Make sure to return a string.
*/
upload: async (buffer, infos, context) => {
- return "https://raw.githubusercontent.com/premieroctet/next-admin/33fcd755a34f1ec5ad53ca8e293029528af814ca/apps/example/public/assets/logo.svg";
+ return faker.image.url({ width: 200, height: 200 });
},
},
},
@@ -157,11 +158,20 @@ export const options: NextAdminOptions = {
"author",
"categories",
"tags",
+ "images",
],
fields: {
content: {
format: "richtext-html",
},
+ images: {
+ format: "file",
+ handler: {
+ upload: async (buffer, infos, context) => {
+ return faker.image.url({ width: 200, height: 200 });
+ },
+ },
+ },
},
},
},
diff --git a/apps/example/prisma/migrations/20250205141119_post_images_list/migration.sql b/apps/example/prisma/migrations/20250205141119_post_images_list/migration.sql
new file mode 100644
index 00000000..7b739a74
--- /dev/null
+++ b/apps/example/prisma/migrations/20250205141119_post_images_list/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Post" ADD COLUMN "images" TEXT[] DEFAULT ARRAY[]::TEXT[];
diff --git a/apps/example/prisma/schema.prisma b/apps/example/prisma/schema.prisma
index 3f3b29ed..3a5d6e72 100644
--- a/apps/example/prisma/schema.prisma
+++ b/apps/example/prisma/schema.prisma
@@ -47,6 +47,7 @@ model Post {
rate Decimal? @db.Decimal(5, 2)
order Int @default(0)
tags String[]
+ images String[] @default([])
}
model Profile {
diff --git a/packages/generator-prisma/package.json b/packages/generator-prisma/package.json
index 40bf0a9f..f24d9ae2 100644
--- a/packages/generator-prisma/package.json
+++ b/packages/generator-prisma/package.json
@@ -44,5 +44,8 @@
"eslint-config-custom": "workspace:*",
"tsconfig": "workspace:*",
"typescript": "^5.6.2"
+ },
+ "peerDependencies": {
+ "@premieroctet/next-admin": "workspace:*"
}
}
diff --git a/packages/next-admin/src/appHandler.ts b/packages/next-admin/src/appHandler.ts
index 2ed9a194..2e0619f2 100644
--- a/packages/next-admin/src/appHandler.ts
+++ b/packages/next-admin/src/appHandler.ts
@@ -5,6 +5,7 @@ import { handleOptionsSearch } from "./handlers/options";
import { deleteResource, submitResource } from "./handlers/resources";
import {
CreateAppHandlerParams,
+ EditFieldsOptions,
ModelAction,
Permission,
RequestContext,
@@ -146,7 +147,12 @@ export const createHandler =
({
);
}
- const body = await getFormValuesFromFormData(await req.formData());
+ const body = await getFormValuesFromFormData(
+ await req.formData(),
+ options?.model?.[resource]?.edit?.fields as EditFieldsOptions<
+ typeof resource
+ >
+ );
const id =
params[paramKey].length === 2
? formatId(resource, params[paramKey].at(-1)!)
diff --git a/packages/next-admin/src/components/Form.tsx b/packages/next-admin/src/components/Form.tsx
index 5bd5531b..9fa80a0d 100644
--- a/packages/next-admin/src/components/Form.tsx
+++ b/packages/next-admin/src/components/Form.tsx
@@ -46,14 +46,14 @@ import {
Permission,
} from "../types";
import { getSchemas } from "../utils/jsonSchema";
-import { formatLabel, slugify } from "../utils/tools";
+import { formatLabel, isFileUploadFormat, slugify } from "../utils/tools";
import FormHeader from "./FormHeader";
import ArrayField from "./inputs/ArrayField";
import BaseInput from "./inputs/BaseInput";
import CheckboxWidget from "./inputs/CheckboxWidget";
import DateTimeWidget from "./inputs/DateTimeWidget";
import DateWidget from "./inputs/DateWidget";
-import FileWidget from "./inputs/FileWidget";
+import FileWidget from "./inputs/FileWidget/FileWidget";
import JsonField from "./inputs/JsonField";
import NullField from "./inputs/NullField";
import SelectWidget from "./inputs/SelectWidget";
@@ -229,20 +229,16 @@ const Form = ({
body: formData,
}
);
-
const result = await response.json();
-
if (result?.validation) {
setValidation(result.validation);
} else {
setValidation(undefined);
}
-
if (result?.data) {
setFormData(result.data);
cleanAll();
}
-
if (result?.deleted) {
return router.replace({
pathname: `${basePath}/${slugify(resource)}`,
@@ -254,7 +250,6 @@ const Form = ({
},
});
}
-
if (result?.created) {
const pathname = result?.redirect
? `${basePath}/${slugify(resource)}`
@@ -269,12 +264,10 @@ const Form = ({
},
});
}
-
if (result?.updated) {
const pathname = result?.redirect
? `${basePath}/${slugify(resource)}`
: location.pathname;
-
if (pathname === location.pathname) {
showMessage({
type: "success",
@@ -292,7 +285,6 @@ const Form = ({
});
}
}
-
if (result?.error) {
showMessage({
type: "error",
@@ -517,29 +509,60 @@ const Form = ({
},
};
- const CustomForm = forwardRef>(
- (props, ref) => {
- const { dirtyFields } = useFormState();
- return (
-