Skip to content

Commit

Permalink
feat: add multifile upload (#532)
Browse files Browse the repository at this point in the history
* feat: add multifile upload

* add page router support, start doc

* update docs

* fix page router form parse

* add images to post resource

* update generator deps

* fix mocks

* fix e2e
  • Loading branch information
foyarash authored Feb 5, 2025
1 parent f4d1d95 commit e390d30
Show file tree
Hide file tree
Showing 22 changed files with 811 additions and 404 deletions.
5 changes: 5 additions & 0 deletions .changeset/gold-baboons-rhyme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@premieroctet/next-admin": minor
---

feat: allow multifile upload (#519)
30 changes: 29 additions & 1 deletion apps/docs/pages/docs/api/model-configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:

<OptionsTable
options={[
{
name: "edit",
type: "Function",
description: "a function that is called before the form data is sent to the database. It takes the submitted form data as its first argument, and the current value in the database as its second argument. If false is returned, the update will not happen."
},
{
name: "delete",
type: "Function",
description: "a function that is called before the record is deleted. It takes the record to delete as its only argument. If false is returned, the deletion will not happen."
}
]}
/>

## NextAdmin Context

The `NextAdmin` context is an object containing the following properties:
Expand Down Expand Up @@ -740,7 +768,7 @@ export const options: NextAdminOptions = {
model: {
User: {
/**
...some configuration
...some configuration
**/
edit: {
display: [
Expand Down
12 changes: 11 additions & 1 deletion apps/example/options.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });
},
},
},
Expand Down Expand Up @@ -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",
Expand All @@ -303,6 +312,7 @@ export const options: NextAdminOptions = {
"author",
"rate",
"tags",
"images",
],
hooks: {
async beforeDb(data, mode, request) {
Expand Down
3 changes: 2 additions & 1 deletion apps/example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 11 additions & 1 deletion apps/example/pageRouterOptions.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });
},
},
},
Expand Down Expand Up @@ -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 });
},
},
},
},
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Post" ADD COLUMN "images" TEXT[] DEFAULT ARRAY[]::TEXT[];
1 change: 1 addition & 0 deletions apps/example/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ model Post {
rate Decimal? @db.Decimal(5, 2)
order Int @default(0)
tags String[]
images String[] @default([])
}

model Profile {
Expand Down
3 changes: 3 additions & 0 deletions packages/generator-prisma/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,8 @@
"eslint-config-custom": "workspace:*",
"tsconfig": "workspace:*",
"typescript": "^5.6.2"
},
"peerDependencies": {
"@premieroctet/next-admin": "workspace:*"
}
}
8 changes: 7 additions & 1 deletion packages/next-admin/src/appHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { handleOptionsSearch } from "./handlers/options";
import { deleteResource, submitResource } from "./handlers/resources";
import {
CreateAppHandlerParams,
EditFieldsOptions,
ModelAction,
Permission,
RequestContext,
Expand Down Expand Up @@ -146,7 +147,12 @@ export const createHandler = <P extends string = "nextadmin">({
);
}

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)!)
Expand Down
89 changes: 56 additions & 33 deletions packages/next-admin/src/components/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)}`,
Expand All @@ -254,7 +250,6 @@ const Form = ({
},
});
}

if (result?.created) {
const pathname = result?.redirect
? `${basePath}/${slugify(resource)}`
Expand All @@ -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",
Expand All @@ -292,7 +285,6 @@ const Form = ({
});
}
}

if (result?.error) {
showMessage({
type: "error",
Expand Down Expand Up @@ -517,29 +509,60 @@ const Form = ({
},
};

const CustomForm = forwardRef<HTMLFormElement, HTMLProps<HTMLFormElement>>(
(props, ref) => {
const { dirtyFields } = useFormState();
return (
<form
{...props}
ref={ref}
onSubmit={(e) => {
e.preventDefault();
const formValues = new FormData(e.target as HTMLFormElement);
const data = new FormData();
dirtyFields.forEach((field) => {
data.append(field, formValues.get(field) as string);
});

// @ts-expect-error
const submitter = e.nativeEvent.submitter as HTMLButtonElement;
data.append(submitter.name, submitter.value);
onSubmit(data);
}}
/>
);
}
const CustomForm = useMemo(
() =>
forwardRef<HTMLFormElement, HTMLProps<HTMLFormElement>>((props, ref) => {
const { dirtyFields } = useFormState();
const { formData } = useFormData();
return (
<form
{...props}
ref={ref}
onSubmit={(e) => {
e.preventDefault();
const formValues = new FormData(e.target as HTMLFormElement);
const data = new FormData();
dirtyFields.forEach((field) => {
const schemaProperties =
schema.properties[field as keyof typeof schema.properties];
const isFieldArrayOfFiles =
schemaProperties?.type === "array" &&
isFileUploadFormat(schemaProperties.format ?? "");

if (isFieldArrayOfFiles) {
const files = formValues
.getAll(field)
.filter(
(file) =>
typeof file === "string" ||
(file instanceof File && !!file.name)
);
const values = formData[
field as keyof typeof formData
] as string[];

values.forEach((val) => {
data.append(field, val);
});

files.forEach((file) => {
data.append(field, file);
});
return;
}

data.append(field, formValues.get(field) as string);
});

// @ts-expect-error
const submitter = e.nativeEvent.submitter as HTMLButtonElement;
data.append(submitter.name, submitter.value);
onSubmit(data);
}}
/>
);
}),
[onSubmit, schema]
);

const RjsfFormComponent = useMemo(
Expand Down
34 changes: 29 additions & 5 deletions packages/next-admin/src/components/inputs/ArrayField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,44 @@ import { FieldProps } from "@rjsf/utils";
import type { CustomInputProps, Enumeration, FormProps } from "../../types";
import MultiSelectWidget from "./MultiSelect/MultiSelectWidget";
import ScalarArrayField from "./ScalarArray/ScalarArrayField";
import FileWidget from "./FileWidget/FileWidget";


const ArrayField = (props: FieldProps & { customInput?: React.ReactElement<CustomInputProps> }) => {
const { formData, onChange, name, disabled, schema, required, formContext, customInput } =
props;
const ArrayField = (
props: FieldProps & { customInput?: React.ReactElement<CustomInputProps> }
) => {
const {
formData,
onChange,
name,
disabled,
schema,
required,
formContext,
customInput,
} = props;

const resourceDefinition: FormProps["schema"] = formContext.schema;

const field =
resourceDefinition.properties[
name as keyof typeof resourceDefinition.properties
name as keyof typeof resourceDefinition.properties
];

if (field?.__nextadmin?.kind === "scalar" && field?.__nextadmin?.isList) {
if (schema.format === "data-url") {
return (
<FileWidget
id={props.name}
name={props.name}
value={props.formData}
disabled={props.disabled}
rawErrors={props.rawErrors}
required={props.required}
schema={props.schema}
/>
);
}

return (
<ScalarArrayField
name={name}
Expand Down
Loading

0 comments on commit e390d30

Please sign in to comment.