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 ( -

{ - 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>((props, ref) => { + const { dirtyFields } = useFormState(); + const { formData } = useFormData(); + return ( + { + 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( diff --git a/packages/next-admin/src/components/inputs/ArrayField.tsx b/packages/next-admin/src/components/inputs/ArrayField.tsx index 728690c3..f3f32f01 100644 --- a/packages/next-admin/src/components/inputs/ArrayField.tsx +++ b/packages/next-admin/src/components/inputs/ArrayField.tsx @@ -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 }) => { - const { formData, onChange, name, disabled, schema, required, formContext, customInput } = - props; +const ArrayField = ( + props: FieldProps & { customInput?: React.ReactElement } +) => { + 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 ( + + ); + } + return ( { - const [file, setFile] = useState(); - const [errors, setErrors] = useState(props.rawErrors); - const [fileIsImage, setFileIsImage] = useState(true); - const [filename, setFilename] = useState(null); - const [fileUrl, setFileUrl] = useState(props.value); - const [isPending, setIsPending] = useState(false); - - const inputRef = useRef(null); - const { t } = useI18n(); - const [isDragging, setIsDragging] = useState(false); - const { setFieldDirty } = useFormState(); - - const handleFileChange = (event: ChangeEvent) => { - const selectedFile = event.target.files; - if (selectedFile) { - setFieldDirty(props.name); - setFile(selectedFile[0]); - } - }; - - useEffect(() => { - if (props.value) { - setIsPending(true); - - const image = document.createElement("img"); - image.src = props.value as string; - image.onload = () => { - setFileIsImage(true); - setIsPending(false); - }; - image.onerror = (e) => { - console.error(e); - setFileIsImage(false); - setIsPending(false); - }; - const filename = getFilenameFromUrl(props.value); - if (filename) { - setFilename(filename); - } - setIsPending(false); - } else { - setIsPending(false); - } - }, [props.value]); - - const handleDelete = () => { - setFile(undefined); - setFileUrl(null); - setFilename(null); - setErrors(undefined); - setFieldDirty(props.name); - }; - - const handleDrop = (event: React.DragEvent) => { - if (!props.disabled) { - event.preventDefault(); - setFile(event.dataTransfer?.files[0]); - setIsDragging(false); - } - }; - - useEffect(() => { - if (inputRef?.current) { - const dataTransfer = new DataTransfer(); - if (file) { - dataTransfer.items.add(file); - const reader = new FileReader(); - reader.onload = () => { - setFileIsImage(file.type.includes("image")); - setFileUrl(reader.result as string); - setFilename(file.name); - }; - - reader.readAsDataURL(file); - } - inputRef.current.files = dataTransfer.files; - } - }, [file]); - - return ( -
- { -
{ - if (!props.disabled) { - evt.preventDefault(); - } - }} - onDragEnter={(evt) => { - if (!props.disabled) { - evt.preventDefault(); - setIsDragging(true); - } - }} - onDragLeave={(evt) => { - if (!props.disabled) { - evt.preventDefault(); - setIsDragging(false); - } - }} - > -
- -
- -

- {t("form.widgets.file_upload.drag_and_drop")} -

-
-
-
- } - {(isPending || errors || fileUrl) && ( - - )} - {errors && ( -
- {errors.join(", ")} -
- )} -
- ); -}; - -export default FileWidget; diff --git a/packages/next-admin/src/components/inputs/FileWidget/FileItem.tsx b/packages/next-admin/src/components/inputs/FileWidget/FileItem.tsx new file mode 100644 index 00000000..b1dc13e3 --- /dev/null +++ b/packages/next-admin/src/components/inputs/FileWidget/FileItem.tsx @@ -0,0 +1,98 @@ +import { useEffect, useMemo, useState } from "react"; +import { getFilenameFromUrl } from "../../../utils/file"; +import clsx from "clsx"; +import Loader from "../../../assets/icons/Loader"; +import { DocumentIcon, XMarkIcon } from "@heroicons/react/24/outline"; + +type Props = { + file: string | File; + hasError: boolean; + onDelete: () => void; + disabled: boolean; +}; + +const FileItem = ({ file, hasError, onDelete, disabled }: Props) => { + const [isPending, setIsPending] = useState(false); + const [fileIsImage, setFileIsImage] = useState(true); + const [filename, setFilename] = useState(() => { + if (file instanceof File) { + return file.name; + } + + return null; + }); + const fileUri = useMemo(() => { + if (typeof file === "string") { + return file; + } + + return URL.createObjectURL(file); + }, [file]); + + useEffect(() => { + if (fileUri) { + setIsPending(true); + + const image = document.createElement("img"); + image.src = fileUri; + image.onload = () => { + setFileIsImage(true); + setIsPending(false); + }; + image.onerror = (e) => { + setFileIsImage(false); + setIsPending(false); + }; + const name = getFilenameFromUrl(fileUri); + if (!filename && name) { + setFilename(name); + } + setIsPending(false); + } else { + setIsPending(false); + } + }, [fileUri]); + + return ( + + ); +}; + +export default FileItem; diff --git a/packages/next-admin/src/components/inputs/FileWidget/FileWidget.tsx b/packages/next-admin/src/components/inputs/FileWidget/FileWidget.tsx new file mode 100644 index 00000000..7e502ee7 --- /dev/null +++ b/packages/next-admin/src/components/inputs/FileWidget/FileWidget.tsx @@ -0,0 +1,197 @@ +import { CloudArrowUpIcon } from "@heroicons/react/24/outline"; +import { WidgetProps } from "@rjsf/utils"; +import clsx from "clsx"; +import { ChangeEvent, useEffect, useRef, useState } from "react"; +import { useFormState } from "../../../context/FormStateContext"; +import { useI18n } from "../../../context/I18nContext"; +import FileItem from "./FileItem"; +import { useFormData } from "../../../context/FormDataContext"; + +type Props = Pick< + WidgetProps, + "rawErrors" | "name" | "disabled" | "id" | "required" | "schema" +> & { + value?: string | string[] | null; +}; + +const FileWidget = (props: Props) => { + const errors = props.rawErrors; + const [files, setFiles] = useState>(() => { + if (!props.value) { + return []; + } + + if (typeof props.value === "string") { + return [props.value]; + } + + return props.value; + }); + const acceptsMultipleFiles = props.schema.type === "array"; + + const inputRef = useRef(null); + const { t } = useI18n(); + const [isDragging, setIsDragging] = useState(false); + const { setFieldDirty } = useFormState(); + const { setFormData } = useFormData(); + + const handleFileChange = (event: ChangeEvent) => { + const selectedFiles = event.target.files; + if (selectedFiles) { + setFieldDirty(props.name); + setFiles((old) => { + if (!acceptsMultipleFiles) { + return [selectedFiles[0]]; + } + + return [...old, ...Array.from(selectedFiles)]; + }); + } + }; + + const handleDelete = (index: number) => { + const stateFiles = files; + setFiles((old) => { + if (!acceptsMultipleFiles) { + return []; + } + const newFiles = [...old]; + newFiles.splice(index, 1); + return newFiles; + }); + setFieldDirty(props.name); + + setFormData((old: any) => { + const newFormData = { ...old }; + if (Array.isArray(props.value)) { + newFormData[props.name] = stateFiles.filter( + (val: unknown, i: number) => i !== index && typeof val === "string" + ); + } else { + newFormData[props.name] = [null]; + } + + return newFormData; + }); + }; + + const handleDrop = (event: React.DragEvent) => { + if (!props.disabled) { + event.preventDefault(); + + setFieldDirty(props.name); + setFiles((old) => { + if (acceptsMultipleFiles) { + return [...old, ...Array.from(event.dataTransfer.files)]; + } + + return [event.dataTransfer.files[0]]; + }); + + setIsDragging(false); + } + }; + + useEffect(() => { + const dataTransfer = new DataTransfer(); + + files.forEach((file) => { + if (typeof file === "string") { + return; + } + + dataTransfer.items.add(file); + }); + + if (inputRef.current) { + inputRef.current.files = dataTransfer.files; + } + }, [files]); + + return ( +
+
{ + if (!props.disabled) { + evt.preventDefault(); + } + }} + onDragEnter={(evt) => { + if (!props.disabled) { + evt.preventDefault(); + setIsDragging(true); + } + }} + onDragLeave={(evt) => { + if (!props.disabled) { + evt.preventDefault(); + setIsDragging(false); + } + }} + > +
+ +
+ +

+ {t("form.widgets.file_upload.drag_and_drop")} +

+
+
+
+
+ {files.map((file, index) => { + return ( + { + handleDelete(index); + }} + /> + ); + })} +
+ {errors && ( +
+ {errors.join(", ")} +
+ )} +
+ ); +}; + +export default FileWidget; diff --git a/packages/next-admin/src/context/FormDataContext.tsx b/packages/next-admin/src/context/FormDataContext.tsx index a77d88da..d6ebfa19 100644 --- a/packages/next-admin/src/context/FormDataContext.tsx +++ b/packages/next-admin/src/context/FormDataContext.tsx @@ -4,11 +4,15 @@ import { useContext, useEffect, useState, + Dispatch, } from "react"; -const FormDataContext = createContext({ +const FormDataContext = createContext<{ + formData: any; + setFormData: Dispatch; +}>({ formData: {}, - setFormData: (_data: any) => {}, + setFormData: () => {}, }); export const useFormData = () => useContext(FormDataContext); diff --git a/packages/next-admin/src/handlers/resources.ts b/packages/next-admin/src/handlers/resources.ts index ce28370c..09b281cf 100644 --- a/packages/next-admin/src/handlers/resources.ts +++ b/packages/next-admin/src/handlers/resources.ts @@ -12,7 +12,7 @@ import { Permission, Schema, SubmitResourceResponse, - UploadParameters, + UploadedFile, } from "../types"; import { hasPermission } from "../utils/permissions"; import { getDataItem } from "../utils/prisma"; @@ -84,7 +84,7 @@ export const deleteResource = async ({ type SubmitResourceParams = { prisma: PrismaClient; resource: ModelName; - body: Record; + body: Record; id?: string | number; options?: NextAdminOptions; schema: Schema; @@ -101,7 +101,13 @@ export const submitResource = async ({ const { __admin_redirect: redirect, ...formValues } = body; const schemaDefinition = schema.definitions[resource]; - const parsedFormData = parseFormData(formValues, schemaDefinition); + const parsedFormData = parseFormData( + formValues, + schemaDefinition, + options?.model?.[resource]?.edit?.fields as EditFieldsOptions< + typeof resource + > + ); const resourceIdField = getModelIdProperty(resource); const fields = options?.model?.[resource]?.edit?.fields as EditFieldsOptions< @@ -112,7 +118,7 @@ export const submitResource = async ({ validate(parsedFormData, fields); const { formattedData, complementaryFormattedData, errors } = - await formattedFormData(formValues, schema, resource, id, fields); + await formattedFormData(formValues, schema, resource, id, fields, prisma); if (errors.length) { return { diff --git a/packages/next-admin/src/pageHandler.ts b/packages/next-admin/src/pageHandler.ts index d6e30d6b..5d31c252 100644 --- a/packages/next-admin/src/pageHandler.ts +++ b/packages/next-admin/src/pageHandler.ts @@ -245,7 +245,7 @@ export const createHandler =

({ }); if (!deleted) { - throw new Error("Deletion failed") + throw new Error("Deletion failed"); } return res.json({ ok: true }); diff --git a/packages/next-admin/src/tests/prismaUtils.test.ts b/packages/next-admin/src/tests/prismaUtils.test.ts index 8e9814bd..312a85e7 100644 --- a/packages/next-admin/src/tests/prismaUtils.test.ts +++ b/packages/next-admin/src/tests/prismaUtils.test.ts @@ -16,6 +16,7 @@ describe("getMappedDataList", () => { rate: new Decimal(5), order: 0, tags: [], + images: [], }, { id: 2, @@ -27,6 +28,7 @@ describe("getMappedDataList", () => { rate: new Decimal(5), order: 1, tags: [], + images: [], }, ]; @@ -64,6 +66,7 @@ describe("optionsFromResource", () => { rate: new Decimal(5), order: 0, tags: [], + images: [], }, { id: 2, @@ -75,6 +78,7 @@ describe("optionsFromResource", () => { rate: new Decimal(5), order: 1, tags: [], + images: [], }, ]; diff --git a/packages/next-admin/src/types.ts b/packages/next-admin/src/types.ts index 2b921ed4..adf957fc 100644 --- a/packages/next-admin/src/types.ts +++ b/packages/next-admin/src/types.ts @@ -309,6 +309,14 @@ export type EditFieldsOptions = { : {}); }; +export type UploadedFile = { + buffer: Buffer; + infos: { + name: string; + type: string | null; + }; +}; + export type Handler< M extends ModelName, P extends Field, @@ -322,10 +330,9 @@ export type Handler< get?: (input: T) => any; /** * an async function that is used only for formats `file` and `data-url`. It takes a buffer as parameter and must return a string. Useful to upload a file to a remote provider. - * @param buffer - * @param infos + * @param file - This object contains the file buffer and information. * @param context - This object contains record information, such as the resource ID. - * @returns + * @returns result - Promise - the file uri */ upload?: ( buffer: Buffer, @@ -340,6 +347,13 @@ export type Handler< resourceId: string | number | undefined; } ) => Promise; + /** + * an async function that is used to remove a file from a remote provider + * + * @param fileUri string - the remote file uri + * @returns success - Promise - true if the deletion succeeded, false otherwise. If false is returned, the file will not be removed from the record. + */ + deleteFile?: (fileUri: string) => Promise; /** * an optional string displayed in the input field as an error message in case of a failure during the upload handler. */ @@ -352,16 +366,6 @@ export type Handler< delete?: (input: T) => Promise | boolean; }; -export type UploadParameters = Parameters< - ( - buffer: Buffer, - infos: { - name: string; - type: string | null; - } - ) => Promise ->; - export type RichTextFormat = "html" | "json"; export type FormatOptions = T extends string @@ -385,7 +389,9 @@ export type FormatOptions = T extends string ? "date" | "date-time" | "time" : never | T extends number ? "updown" | "range" - : never; + : never | T extends string[] + ? "file" + : never; export type ListExport = { /** @@ -494,10 +500,10 @@ export type EditModelHooks = { * @throws HookError - if the hook fails, the status and message will be sent in the handler's response */ beforeDb?: ( - data: Record, + data: Record, mode: "create" | "edit", request: NextRequest | NextApiRequest - ) => Promise>; + ) => Promise>; /** * a function that is called after the form submission. It takes the response of the db insertion as a parameter. * @param data diff --git a/packages/next-admin/src/utils/server.ts b/packages/next-admin/src/utils/server.ts index 8d6643e4..799dd634 100644 --- a/packages/next-admin/src/utils/server.ts +++ b/packages/next-admin/src/utils/server.ts @@ -20,10 +20,16 @@ import { Schema, SchemaDefinitions, SchemaProperty, - UploadParameters, + UploadedFile, } from "../types"; import { getRawData } from "./prisma"; -import { isNativeFunction, isUploadParameters, pipe } from "./tools"; +import { + isFileUploadFormat, + isNativeFunction, + isUploadFile, + pipe, + uncapitalize, +} from "./tools"; export const getJsonSchema = (): Schema => { try { @@ -425,7 +431,8 @@ export const transformData = ( ] : item[modelRelationIdField], data: { - modelName: deepRelationModel?.__nextadmin?.type as ModelName, + modelName: + (deepRelationModel?.__nextadmin?.type as ModelName) ?? null, }, }; }); @@ -556,7 +563,8 @@ export const findRelationInData = ( export const parseFormData = ( formData: AdminFormData, - schemaResource: SchemaDefinitions[ModelName] + schemaResource: SchemaDefinitions[ModelName], + editFieldOptions?: EditFieldsOptions ): Partial> => { const parsedData: Partial> = {}; Object.entries(schemaResource.properties).forEach(([property, value]) => { @@ -565,6 +573,7 @@ export const parseFormData = ( const propertyNextAdminData = value.__nextadmin; const propertyType = propertyNextAdminData?.type; const propertyKind = propertyNextAdminData?.kind; + if (propertyKind === "object") { if (formData[formPropertyName]) { parsedData[formPropertyName] = formData[ @@ -576,7 +585,11 @@ export const parseFormData = ( } } else if ( propertyNextAdminData?.isList && - propertyNextAdminData.kind === "scalar" + propertyNextAdminData.kind === "scalar" && + !isFileUploadFormat( + editFieldOptions?.[property as keyof typeof editFieldOptions] + ?.format ?? "" + ) ) { parsedData[formPropertyName] = JSON.parse( formData[formPropertyName]! @@ -641,6 +654,84 @@ const getExplicitManyToManyTablePrimaryKey = ( }; }; +type HandleUploadPropertyParams = { + files: UploadedFile[]; + resourceId: string | number | undefined; + editOptions?: EditFieldsOptions; + property: keyof ScalarField; +}; + +const handleUploadProperty = async ({ + files, + resourceId, + editOptions, + property, +}: HandleUploadPropertyParams) => { + const uploadHandler = editOptions?.[property]?.handler?.upload; + + if (!uploadHandler) { + console.warn("You need to provide an upload handler for data-url format"); + } else { + return Promise.all( + files.map(async (file) => { + if (typeof file === "string") { + return; + } + + const uploadResult = await uploadHandler(file.buffer, file.infos, { + resourceId, + }); + if (typeof uploadResult !== "string") { + console.warn( + "Upload handler must return a string, fallback to no-op for field " + + property.toString() + ); + return; + } + + return uploadResult; + }) + ); + } +}; + +type HandleFileDeletionParams = { + fileUris: string[]; + editOptions?: EditFieldsOptions; + property: keyof ScalarField; +}; + +const handleFileDeletion = async ({ + fileUris, + editOptions, + property, +}: HandleFileDeletionParams) => { + const deleteHandler = editOptions?.[property]?.handler?.deleteFile; + + if (!deleteHandler) { + console.warn( + "Delete handler not provided, files will not be deleted from your remote storage" + ); + return fileUris; + } else { + const deletedFiles = await Promise.all( + fileUris.map(async (uri) => { + try { + const isDeleted = await deleteHandler(uri); + + if (isDeleted) { + return uri; + } + } catch (e) { + console.error("An error occured while deleting file", e); + } + }) + ); + + return deletedFiles.filter(Boolean) as string[]; + } +}; + /** * Convert the form data to the format expected by Prisma * @@ -649,13 +740,15 @@ const getExplicitManyToManyTablePrimaryKey = ( * @param resource * @param resourceId * @param editOptions + * @param prisma */ export const formattedFormData = async ( formData: AdminFormData, schema: Schema, resource: M, resourceId: string | number | undefined, - editOptions?: EditFieldsOptions + editOptions: EditFieldsOptions | undefined, + prisma: PrismaClient ) => { const formattedData: any = {}; const complementaryFormattedData: any = {}; @@ -665,6 +758,16 @@ export const formattedFormData = async ( const resourceSchema = schema.definitions[ modelName ] as SchemaDefinitions[ModelName]; + const resourceIdProperty = getModelIdProperty(resource); + + const currentRecord = resourceId + ? // @ts-expect-error + await prisma[uncapitalize(resource)].findUnique({ + where: { + [resourceIdProperty]: resourceId, + }, + }) + : undefined; const results = await Promise.allSettled( Object.entries(resourceSchema.properties).map(async ([property, value]) => { @@ -871,9 +974,10 @@ export const formattedFormData = async ( } else if (propertyKind === "scalar" && isList) { const propertyName = property as keyof ScalarField; - const formDataValue = JSON.parse(formData[propertyName]!) as - | string[] - | number[]; + const formDataValue = + typeof formData[propertyName] === "string" + ? JSON.parse(formData[propertyName]!) + : formData[propertyName]; if ( propertyType === "Int" || @@ -881,12 +985,54 @@ export const formattedFormData = async ( propertyType === "Decimal" ) { formattedData[propertyName] = { - set: formDataValue + set: (formDataValue as string[] | number[]) .map((item) => !isNaN(Number(item)) ? Number(item) : undefined ) .filter(Boolean), }; + } else if ( + propertyType === "String" && + isFileUploadFormat(editOptions?.[propertyName]?.format ?? "") + ) { + const uploadErrorMessage = + editOptions?.[propertyName]?.handler?.uploadErrorMessage; + const deletedFiles: string[] = currentRecord[propertyName]?.filter( + (existing: string) => { + return !formData[propertyName]?.includes(existing); + } + ); + try { + const unsetFiles = await handleFileDeletion({ + fileUris: deletedFiles, + editOptions, + property: propertyName, + }); + const filteredFiles = currentRecord[propertyName].filter( + (name: string) => !unsetFiles.includes(name) + ); + const uploadedFiles = await handleUploadProperty({ + files: ( + formData[propertyName] as unknown as (string | UploadedFile)[] + ).filter(isUploadFile), + resourceId, + editOptions, + property: propertyName, + }); + formattedData[propertyName] = { + set: [ + ...filteredFiles, + ...(uploadedFiles?.filter(Boolean) ?? []), + ], + }; + } catch (e) { + errors.push({ + field: propertyName.toString(), + message: + uploadErrorMessage ?? + `Upload failed: ${(e as Error).message}`, + }); + } } else { formattedData[propertyName] = { set: formDataValue, @@ -931,43 +1077,34 @@ export const formattedFormData = async ( : null; } else if ( propertyType === "String" && - ["data-url", "file"].includes( - editOptions?.[propertyName]?.format ?? "" - ) && - isUploadParameters(formData[propertyName]) + isFileUploadFormat(editOptions?.[propertyName]?.format ?? "") ) { - const uploadHandler = editOptions?.[propertyName]?.handler?.upload; const uploadErrorMessage = editOptions?.[propertyName]?.handler?.uploadErrorMessage; - - if (!uploadHandler) { - console.warn( - "You need to provide an upload handler for data-url format" - ); - } else { - try { - const uploadResult = await uploadHandler( - ...(formData[propertyName] as unknown as UploadParameters), - { - resourceId, - } - ); - if (typeof uploadResult !== "string") { - console.warn( - "Upload handler must return a string, fallback to no-op for field " + - propertyName.toString() - ); - } else { - formattedData[propertyName] = uploadResult; - } - } catch (e) { - errors.push({ - field: propertyName.toString(), - message: - uploadErrorMessage ?? - `Upload failed: ${(e as Error).message}`, + try { + if (currentRecord?.[propertyName]) { + await handleFileDeletion({ + fileUris: [currentRecord[propertyName]], + property: propertyName, + editOptions, }); } + const uploaded = await handleUploadProperty({ + files: formData[propertyName] as unknown as UploadedFile[], + resourceId, + editOptions, + property: propertyName, + }); + if (uploaded?.length) { + formattedData[propertyName] = uploaded[0]; + } + } catch (e) { + errors.push({ + field: propertyName.toString(), + message: + uploadErrorMessage ?? + `Upload failed: ${(e as Error).message}`, + }); } } else { formattedData[propertyName] = formData[propertyName]; @@ -1215,9 +1352,9 @@ export const getFormDataValues = async (req: IncomingMessage) => { }); }, }); - return new Promise>( + return new Promise>>( (resolve, reject) => { - const files = {} as Record; + const files = {} as Record>; form.on("fileBegin", (name, file) => { // @ts-expect-error @@ -1229,18 +1366,18 @@ export const getFormDataValues = async (req: IncomingMessage) => { callback(); }, final(callback) { - if (!file.originalFilename) { - files[name] = [null]; - } else { - files[name] = [ - [ - Buffer.concat(chunks), - { - name: file.originalFilename, - type: file.mimetype, - }, - ], - ]; + if (file.originalFilename) { + if (!files[name]) { + files[name] = []; + } + + files[name].push({ + buffer: Buffer.concat(chunks), + infos: { + name: file.originalFilename, + type: file.mimetype, + }, + }); } callback(); }, @@ -1252,48 +1389,84 @@ export const getFormDataValues = async (req: IncomingMessage) => { if (err) { reject(err); } - const joinedFormData = Object.entries({ ...fields, ...files }).reduce( - (acc, [key, value]) => { - if (Array.isArray(value)) { - acc[key] = value[0]; + + resolve({ + ...Object.entries(fields).reduce( + (acc, [key, value]) => { + if (Array.isArray(value)) { + acc[key] = value[0]; + } + + return acc; + }, + {} as Record + ), + ...Object.entries(files).reduce((acc, [key, value]) => { + if (key in fields && Array.isArray(fields[key])) { + acc[key] = [...fields[key], ...value]; } + return acc; - }, - {} as Record - ); - resolve(joinedFormData); + }, files), + }); }); } ); }; -export const getFormValuesFromFormData = async (formData: FormData) => { - const tmpFormValues = {} as Record; +export const getFormValuesFromFormData = async ( + formData: FormData, + editFieldOptions?: EditFieldsOptions +) => { + const tmpFormValues = {} as Record; formData.forEach((val, key) => { if (key.startsWith("$ACTION")) { return; } - tmpFormValues[key] = val; + + if ( + isFileUploadFormat( + editFieldOptions?.[key as keyof typeof editFieldOptions]?.format ?? "" + ) + ) { + tmpFormValues[key] = formData.getAll(key) as (File | string)[]; + } else { + tmpFormValues[key] = val as string | null; + } }); - const formValues = {} as Record; + const formValues = {} as Record< + string, + string | Array | null + >; await Promise.allSettled( Object.entries(tmpFormValues).map(async ([key, value]) => { - if (typeof value === "object") { - const file = value as unknown as File; - if (file.size === 0) { - formValues[key] = null; - return; - } - const buffer = await file.arrayBuffer(); - formValues[key] = [ - Buffer.from(buffer), - { - name: file.name, - type: file.type, - }, - ]; + if ( + isFileUploadFormat( + editFieldOptions?.[key as keyof typeof editFieldOptions]?.format ?? "" + ) && + Array.isArray(value) + ) { + const parameters = await Promise.all( + value.map(async (file) => { + // We are not uploading a new file + if (typeof file === "string") { + return file; + } + + const buffer = await file.arrayBuffer(); + + return { + buffer: Buffer.from(buffer), + infos: { + name: file.name, + type: file.type, + }, + } satisfies UploadedFile; + }) + ); + formValues[key] = parameters; } else { formValues[key] = value as string; } diff --git a/packages/next-admin/src/utils/tools.ts b/packages/next-admin/src/utils/tools.ts index 41037c7a..e6ea213d 100644 --- a/packages/next-admin/src/utils/tools.ts +++ b/packages/next-admin/src/utils/tools.ts @@ -1,5 +1,5 @@ import React from "react"; -import { UploadParameters } from "../types"; +import { UploadedFile } from "../types"; export const capitalize = (str: T): Capitalize => { let capitalizedStr = str.charAt(0).toLocaleUpperCase() + str.slice(1); @@ -72,15 +72,8 @@ export const formatLabel = (label: string) => { return capitalize(spacedLabel.toLowerCase()); }; -//Create a function that check if object satifies UploadParameters -export const isUploadParameters = (obj: any): obj is UploadParameters => { - return ( - obj?.length === 2 && - Buffer.isBuffer(obj[0]) && - typeof obj[1] === "object" && - "name" in obj[1] && - "type" in obj[1] - ); +export const isUploadFile = (obj: any): obj is UploadedFile => { + return typeof obj === "object" && "buffer" in obj && "infos" in obj; }; export const getDisplayedValue = ( @@ -99,3 +92,10 @@ export const getDisplayedValue = ( return ""; } }; + +export const getDeletedFilesFieldName = (field: string) => + `${field}__nextadmin_deleted`; + +export const isFileUploadFormat = (format: string) => { + return ["data-url", "file"].includes(format); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acd45757..ff55c99c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ importers: version: 3.4.2 turbo: specifier: latest - version: 2.3.3 + version: 2.4.0 apps/docs: dependencies: @@ -105,6 +105,9 @@ importers: apps/example: dependencies: + '@faker-js/faker': + specifier: ^9.4.0 + version: 9.4.0 '@heroicons/react': specifier: ^2.0.18 version: 2.1.5(react@19.0.0) @@ -253,7 +256,7 @@ importers: version: 8.10.0(eslint@7.32.0) eslint-config-turbo: specifier: latest - version: 2.3.3(eslint@7.32.0) + version: 2.4.0(eslint@7.32.0)(turbo@2.4.0) eslint-plugin-react: specifier: 7.31.8 version: 7.31.8(eslint@7.32.0) @@ -267,6 +270,9 @@ importers: packages/generator-prisma: dependencies: + '@premieroctet/next-admin': + specifier: workspace:* + version: link:../next-admin '@premieroctet/next-admin-json-schema': specifier: workspace:^ version: link:../json-schema @@ -1140,6 +1146,10 @@ packages: resolution: {integrity: sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==} engines: {node: ^10.12.0 || >=12.0.0} + '@faker-js/faker@9.4.0': + resolution: {integrity: sha512-85+k0AxaZSTowL0gXp8zYWDIrWclTbRPg/pm/V0dSFZ6W6D4lhcG3uuZl4zLsEKfEvs69xDbLN2cHQudwp95JA==} + engines: {node: '>=18.0.0', npm: '>=9.0.0'} + '@floating-ui/core@1.6.8': resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==} @@ -3728,10 +3738,11 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-config-turbo@2.3.3: - resolution: {integrity: sha512-cM9wSBYowQIrjx2MPCzFE6jTnG4vpTPJKZ/O+Ps3CqrmGK/wtNOsY6WHGMwLtKY/nNbgRahAJH6jGVF6k2coOg==} + eslint-config-turbo@2.4.0: + resolution: {integrity: sha512-AiRdy83iwyG4+iMSxXQGUbEClxkGxSlXYH8E2a+0972ao75OWnlDBiiuLMOzDpJubR+QVGC4zonn29AIFCSbFw==} peerDependencies: eslint: '>6.6.0' + turbo: '>2.0.0' eslint-import-resolver-node@0.3.9: resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} @@ -3792,10 +3803,11 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 - eslint-plugin-turbo@2.3.3: - resolution: {integrity: sha512-j8UEA0Z+NNCsjZep9G5u5soDQHcXq/x4amrwulk6eHF1U91H2qAjp5I4jQcvJewmccCJbVp734PkHHTRnosjpg==} + eslint-plugin-turbo@2.4.0: + resolution: {integrity: sha512-qCgoRi/OTc1VMxab7+sdKiV1xlkY4qjK9sM+kS7+WogrB1DxLguJSQXvk4HA13SD5VmJsq+8FYOw5q4EUk6Ixg==} peerDependencies: eslint: '>6.6.0' + turbo: '>2.0.0' eslint-scope@5.1.1: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} @@ -6496,38 +6508,38 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo-darwin-64@2.3.3: - resolution: {integrity: sha512-bxX82xe6du/3rPmm4aCC5RdEilIN99VUld4HkFQuw+mvFg6darNBuQxyWSHZTtc25XgYjQrjsV05888w1grpaA==} + turbo-darwin-64@2.4.0: + resolution: {integrity: sha512-kVMScnPUa3R4n7woNmkR15kOY0aUwCLJcUyH5UC59ggKqr5HIHwweKYK8N1pwBQso0LQF4I9i93hIzfJguCcwQ==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.3.3: - resolution: {integrity: sha512-DYbQwa3NsAuWkCUYVzfOUBbSUBVQzH5HWUFy2Kgi3fGjIWVZOFk86ss+xsWu//rlEAfYwEmopigsPYSmW4X15A==} + turbo-darwin-arm64@2.4.0: + resolution: {integrity: sha512-8JObIpfun1guA7UlFR5jC/SOVm49lRscxMxfg5jZ5ABft79rhFC+ygN9AwAhGKv6W2DUhIh2xENkSgu4EDmUyg==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.3.3: - resolution: {integrity: sha512-eHj9OIB0dFaP6BxB88jSuaCLsOQSYWBgmhy2ErCu6D2GG6xW3b6e2UWHl/1Ho9FsTg4uVgo4DB9wGsKa5erjUA==} + turbo-linux-64@2.4.0: + resolution: {integrity: sha512-xWDGGcRlBuGV7HXWAVuTY6vsQi4aZxGMAnuiuNDg8Ij1aHGohOM0RUsWMXjxz4vuJmjk9+/D6NQqHH3AJEXezg==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.3.3: - resolution: {integrity: sha512-NmDE/NjZoDj1UWBhMtOPmqFLEBKhzGS61KObfrDEbXvU3lekwHeoPvAMfcovzswzch+kN2DrtbNIlz+/rp8OCg==} + turbo-linux-arm64@2.4.0: + resolution: {integrity: sha512-c3En99xMguc/Pdtk/rZP53LnDdw0W6lgUc04he8r8F+UHYSNvgzHh0WGXXmCC6lGbBH72kPhhGx4bAwyvi7dug==} cpu: [arm64] os: [linux] - turbo-windows-64@2.3.3: - resolution: {integrity: sha512-O2+BS4QqjK3dOERscXqv7N2GXNcqHr9hXumkMxDj/oGx9oCatIwnnwx34UmzodloSnJpgSqjl8iRWiY65SmYoQ==} + turbo-windows-64@2.4.0: + resolution: {integrity: sha512-/gOORuOlyA8JDPzyA16CD3wvyRcuBFePa1URAnFUof9hXQmKxK0VvSDO79cYZFsJSchCKNJpckUS0gYxGsWwoA==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.3.3: - resolution: {integrity: sha512-dW4ZK1r6XLPNYLIKjC4o87HxYidtRRcBeo/hZ9Wng2XM/MqqYkAyzJXJGgRMsc0MMEN9z4+ZIfnSNBrA0b08ag==} + turbo-windows-arm64@2.4.0: + resolution: {integrity: sha512-/DJIdTFijEMM5LSiEpSfarDOMOlYqJV+EzmppqWtHqDsOLF4hbbIBH9sJR6OOp5dURAu5eURBYdmvBRz9Lo6TA==} cpu: [arm64] os: [win32] - turbo@2.3.3: - resolution: {integrity: sha512-DUHWQAcC8BTiUZDRzAYGvpSpGLiaOQPfYXlCieQbwUvmml/LRGIe3raKdrOPOoiX0DYlzxs2nH6BoWJoZrj8hA==} + turbo@2.4.0: + resolution: {integrity: sha512-ah/yQp2oMif1X0u7fBJ4MLMygnkbKnW5O8SG6pJvloPCpHfFoZctkSVQiJ3VnvNTq71V2JJIdwmOeu1i34OQyg==} hasBin: true twoslash-protocol@0.2.12: @@ -7490,6 +7502,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@faker-js/faker@9.4.0': {} + '@floating-ui/core@1.6.8': dependencies: '@floating-ui/utils': 0.2.8 @@ -10464,10 +10478,11 @@ snapshots: dependencies: eslint: 7.32.0 - eslint-config-turbo@2.3.3(eslint@7.32.0): + eslint-config-turbo@2.4.0(eslint@7.32.0)(turbo@2.4.0): dependencies: eslint: 7.32.0 - eslint-plugin-turbo: 2.3.3(eslint@7.32.0) + eslint-plugin-turbo: 2.4.0(eslint@7.32.0)(turbo@2.4.0) + turbo: 2.4.0 eslint-import-resolver-node@0.3.9: dependencies: @@ -10569,10 +10584,11 @@ snapshots: semver: 6.3.1 string.prototype.matchall: 4.0.11 - eslint-plugin-turbo@2.3.3(eslint@7.32.0): + eslint-plugin-turbo@2.4.0(eslint@7.32.0)(turbo@2.4.0): dependencies: dotenv: 16.0.3 eslint: 7.32.0 + turbo: 2.4.0 eslint-scope@5.1.1: dependencies: @@ -14122,32 +14138,32 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo-darwin-64@2.3.3: + turbo-darwin-64@2.4.0: optional: true - turbo-darwin-arm64@2.3.3: + turbo-darwin-arm64@2.4.0: optional: true - turbo-linux-64@2.3.3: + turbo-linux-64@2.4.0: optional: true - turbo-linux-arm64@2.3.3: + turbo-linux-arm64@2.4.0: optional: true - turbo-windows-64@2.3.3: + turbo-windows-64@2.4.0: optional: true - turbo-windows-arm64@2.3.3: + turbo-windows-arm64@2.4.0: optional: true - turbo@2.3.3: + turbo@2.4.0: optionalDependencies: - turbo-darwin-64: 2.3.3 - turbo-darwin-arm64: 2.3.3 - turbo-linux-64: 2.3.3 - turbo-linux-arm64: 2.3.3 - turbo-windows-64: 2.3.3 - turbo-windows-arm64: 2.3.3 + turbo-darwin-64: 2.4.0 + turbo-darwin-arm64: 2.4.0 + turbo-linux-64: 2.4.0 + turbo-linux-arm64: 2.4.0 + turbo-windows-64: 2.4.0 + turbo-windows-arm64: 2.4.0 twoslash-protocol@0.2.12: {}