From f662f66e1a3f449a63a6c2be21159c189c736319 Mon Sep 17 00:00:00 2001 From: k3 Date: Wed, 18 Mar 2026 05:28:50 +0100 Subject: [PATCH] (issue #13) Image uploader --- apps/docs/pages/docs/api-reference/fields.mdx | 1 + .../pages/docs/api-reference/fields/image.mdx | 140 +++++++ .../ImageField/__tests__/ImageField.test.tsx | 351 ++++++++++++++++++ .../AutoField/fields/ImageField/index.tsx | 189 ++++++++++ .../fields/ImageField/styles.module.css | 132 +++++++ .../components/AutoField/fields/index.tsx | 1 + packages/core/components/AutoField/index.tsx | 3 + packages/core/types/Fields.ts | 8 + 8 files changed, 825 insertions(+) create mode 100644 apps/docs/pages/docs/api-reference/fields/image.mdx create mode 100644 packages/core/components/AutoField/fields/ImageField/__tests__/ImageField.test.tsx create mode 100644 packages/core/components/AutoField/fields/ImageField/index.tsx create mode 100644 packages/core/components/AutoField/fields/ImageField/styles.module.css diff --git a/apps/docs/pages/docs/api-reference/fields.mdx b/apps/docs/pages/docs/api-reference/fields.mdx index 815c6b7254..a98e5c8dd6 100644 --- a/apps/docs/pages/docs/api-reference/fields.mdx +++ b/apps/docs/pages/docs/api-reference/fields.mdx @@ -6,6 +6,7 @@ A field represents a user input shown in the Puck interface. - [Array](fields/array) - Render a list of items with a subset of fields. - [Custom](fields/custom) - Implement a field with a custom UI. - [External](fields/external) - Select data from a list, typically populated via a third-party API. +- [Image](fields/image) - Upload or link to an image, with a URL input and optional upload handler. - [Number](fields/number) - Render a `number` input. - [Object](fields/object) - Render a subset of fields. - [Radio](fields/radio) - Render a `radio` input with a list of options. diff --git a/apps/docs/pages/docs/api-reference/fields/image.mdx b/apps/docs/pages/docs/api-reference/fields/image.mdx new file mode 100644 index 0000000000..8072e8d17e --- /dev/null +++ b/apps/docs/pages/docs/api-reference/fields/image.mdx @@ -0,0 +1,140 @@ +import { ConfigPreview } from "@/docs/components/Preview"; + +# Image + +Upload or link to an image, with an optional upload handler. Extends [Base](base). + +The field provides both a URL text input and a file upload dropzone. Users can paste a third-party image URL directly, or upload a file which will prepopulate the URL via the `onUpload` callback. The stored value is always a plain URL string, matching `` usage. + +```tsx {5-9} copy +const config = { + components: { + Example: { + fields: { + heroImage: { + type: "image", + onUpload: async (file) => { + // Upload to your backend, S3, Cloudinary, etc. + const url = URL.createObjectURL(file); + return url; + }, + }, + }, + render: ({ heroImage }) => { + return heroImage ? ( + + ) : ( +

No image selected

+ ); + }, + }, + }, +}; +``` + +## Params + +| Param | Example | Type | Status | +| ------------------------------- | ---------------------------------------- | -------- | -------- | +| [`type`](#type) | `type: "image"` | "image" | Required | +| [`onUpload()`](#onuploadfile) | `onUpload: async (file) => "https://…"` | Function | Required | +| [`placeholder`](#placeholder) | `placeholder: "Paste image URL…"` | String | - | +| [`validate()`](#validatefile) | `validate: (file) => null` | Function | - | + +## Required params + +### `type` + +The type of the field. Must be `"image"` for Image fields. + +```tsx {6} copy +const config = { + components: { + Example: { + fields: { + heroImage: { + type: "image", + onUpload: async (file) => "https://example.com/image.png", + }, + }, + // ... + }, + }, +}; +``` + +### `onUpload(file)` + +A function that receives a [File](https://developer.mozilla.org/en-US/docs/Web/API/File) object and returns a Promise resolving to the URL string of the uploaded image. The returned URL is written to the URL input field. + +This is where you integrate your own upload backend (S3, Cloudinary, custom API, etc.). + +```tsx {7-12} copy +const config = { + components: { + Example: { + fields: { + heroImage: { + type: "image", + onUpload: async (file) => { + const formData = new FormData(); + formData.append("file", file); + const res = await fetch("/api/upload", { method: "POST", body: formData }); + const { url } = await res.json(); + return url; + }, + }, + }, + // ... + }, + }, +}; +``` + +## Optional params + +### `placeholder` + +The placeholder text for the URL input when no image is set. Defaults to `"Paste image URL..."`. + +```tsx {8} copy +const config = { + components: { + Example: { + fields: { + heroImage: { + type: "image", + onUpload: async (file) => "https://example.com/image.png", + placeholder: "Enter hero image URL", + }, + }, + // ... + }, + }, +}; +``` + +### `validate(file)` + +A function that receives a [File](https://developer.mozilla.org/en-US/docs/Web/API/File) object and returns an error message string, or `null` if the file is valid. Called before `onUpload`. This only applies to file uploads, not manually entered URLs. + +```tsx {8-12} copy +const config = { + components: { + Example: { + fields: { + heroImage: { + type: "image", + onUpload: async (file) => "https://example.com/image.png", + validate: (file) => { + if (!file.type.startsWith("image/")) return "Must be an image"; + if (file.size > 5_000_000) return "Max 5MB"; + return null; + }, + }, + }, + // ... + }, + }, +}; +``` diff --git a/packages/core/components/AutoField/fields/ImageField/__tests__/ImageField.test.tsx b/packages/core/components/AutoField/fields/ImageField/__tests__/ImageField.test.tsx new file mode 100644 index 0000000000..d51a90f2ac --- /dev/null +++ b/packages/core/components/AutoField/fields/ImageField/__tests__/ImageField.test.tsx @@ -0,0 +1,351 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { ImageField } from ".."; + +// Mock useDeepField +let mockFieldValue: string | undefined = undefined; +jest.mock("../../../lib/use-deep-field", () => ({ + useDeepField: () => mockFieldValue, +})); + +// Mock useLocalValue to track URL input changes +let mockLocalOnChange: jest.Mock = jest.fn(); +jest.mock("../../../lib/use-local-value", () => ({ + useLocalValue: (_path: string, onChange: (val: any) => void) => { + mockLocalOnChange = jest.fn((val: any) => onChange(val)); + return [mockFieldValue ?? "", mockLocalOnChange]; + }, +})); + +// Mock getClassNameFactory to return a simple function +jest.mock("../../../../../lib/get-class-name-factory", () => ({ + __esModule: true, + default: (base: string) => { + return (modifiers?: string | Record) => { + if (!modifiers) return base; + if (typeof modifiers === "string") return `${base}-${modifiers}`; + return [ + base, + ...Object.entries(modifiers) + .filter(([, v]) => v) + .map(([k]) => `${base}--${k}`), + ].join(" "); + }; + }, +})); + +const defaultProps = { + field: { + type: "image" as const, + onUpload: jest.fn().mockResolvedValue("https://example.com/image.png"), + }, + onChange: jest.fn(), + id: "test-image", + name: "test-image", + label: "Test Image", + Label: ({ children, label, icon, el: El = "label" }: any) => ( + + {label} + {icon} + {children} + + ), + readOnly: false, + value: undefined, +}; + +function createFile(name = "test.png", type = "image/png", size = 1024) { + const file = new File(["x".repeat(size)], name, { type }); + return file; +} + +describe("ImageField", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFieldValue = undefined; + }); + + it("renders URL input", () => { + render(); + const urlInput = document.querySelector( + 'input[type="text"]' + ) as HTMLInputElement; + expect(urlInput).toBeTruthy(); + expect(urlInput.placeholder).toBe("Paste image URL..."); + }); + + it("renders dropzone when value is empty", () => { + render(); + expect(screen.getByText("Click or drag to upload")).toBeTruthy(); + }); + + it("renders custom placeholder on URL input", () => { + render( + + ); + const urlInput = document.querySelector( + 'input[type="text"]' + ) as HTMLInputElement; + expect(urlInput.placeholder).toBe("Enter hero image URL"); + }); + + it("renders preview when value is a URL", () => { + mockFieldValue = "https://example.com/photo.jpg"; + render(); + const img = screen.getByAltText("Test Image") as HTMLImageElement; + expect(img.src).toBe("https://example.com/photo.jpg"); + }); + + it("renders remove button when value is set", () => { + mockFieldValue = "https://example.com/photo.jpg"; + render(); + expect(screen.getByLabelText("Remove image")).toBeTruthy(); + }); + + it("calls onUpload when file selected, then onChange with returned URL", async () => { + const onUpload = jest.fn().mockResolvedValue("https://cdn.example.com/uploaded.png"); + const onChange = jest.fn(); + + render( + + ); + + const fileInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + const file = createFile(); + + fireEvent.change(fileInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(onUpload).toHaveBeenCalledWith(file); + expect(onChange).toHaveBeenCalledWith("https://cdn.example.com/uploaded.png"); + }); + }); + + it("shows loading state during upload", async () => { + let resolveUpload: (url: string) => void; + const uploadPromise = new Promise((resolve) => { + resolveUpload = resolve; + }); + const onUpload = jest.fn().mockReturnValue(uploadPromise); + + render( + + ); + + const fileInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [createFile()] } }); + + await waitFor(() => { + expect(screen.getByText("Uploading...")).toBeTruthy(); + }); + + resolveUpload!("https://example.com/done.png"); + + await waitFor(() => { + expect(screen.queryByText("Uploading...")).toBeFalsy(); + }); + }); + + it("shows error when onUpload rejects", async () => { + const onUpload = jest.fn().mockRejectedValue(new Error("Network error")); + + render( + + ); + + const fileInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [createFile()] } }); + + await waitFor(() => { + expect(screen.getByText("Network error")).toBeTruthy(); + }); + }); + + it("shows generic error when onUpload throws non-Error", async () => { + const onUpload = jest.fn().mockRejectedValue("something went wrong"); + + render( + + ); + + const fileInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [createFile()] } }); + + await waitFor(() => { + expect(screen.getByText("Upload failed")).toBeTruthy(); + }); + }); + + it("remove button calls onChange with empty string", () => { + mockFieldValue = "https://example.com/photo.jpg"; + const onChange = jest.fn(); + + render(); + + fireEvent.click(screen.getByLabelText("Remove image")); + expect(onChange).toHaveBeenCalledWith(""); + }); + + it("respects readOnly - hides remove button", () => { + mockFieldValue = "https://example.com/photo.jpg"; + + render(); + + expect(screen.queryByLabelText("Remove image")).toBeFalsy(); + }); + + it("respects readOnly - disables file input", () => { + render(); + + const fileInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + expect(fileInput.disabled).toBe(true); + }); + + it("validates file via validate callback - shows error on invalid", () => { + const validate = jest.fn().mockReturnValue("File too large"); + const onUpload = jest.fn(); + + render( + + ); + + const fileInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [createFile()] } }); + + expect(screen.getByText("File too large")).toBeTruthy(); + expect(onUpload).not.toHaveBeenCalled(); + }); + + it("validates file via validate callback - proceeds when valid", async () => { + const validate = jest.fn().mockReturnValue(null); + const onUpload = jest.fn().mockResolvedValue("https://example.com/ok.png"); + const onChange = jest.fn(); + + render( + + ); + + const fileInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + const file = createFile(); + fireEvent.change(fileInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(validate).toHaveBeenCalledWith(file); + expect(onUpload).toHaveBeenCalledWith(file); + expect(onChange).toHaveBeenCalledWith("https://example.com/ok.png"); + }); + }); + + it("returns null for non-image field type", () => { + const { container } = render( + + ); + expect(container.innerHTML).toBe(""); + }); + + it("URL input calls onChange when typed into", () => { + const onChange = jest.fn(); + + render(); + + const urlInput = document.querySelector( + 'input[type="text"]' + ) as HTMLInputElement; + fireEvent.change(urlInput, { + target: { value: "https://example.com/external.jpg" }, + }); + + expect(mockLocalOnChange).toHaveBeenCalledWith( + "https://example.com/external.jpg" + ); + }); + + it("URL input is prepopulated when value exists", () => { + mockFieldValue = "https://example.com/photo.jpg"; + + render(); + + const urlInput = document.querySelector( + 'input[type="text"]' + ) as HTMLInputElement; + expect(urlInput.value).toBe("https://example.com/photo.jpg"); + }); + + it("URL input is readonly when readOnly", () => { + render(); + + const urlInput = document.querySelector( + 'input[type="text"]' + ) as HTMLInputElement; + expect(urlInput.readOnly).toBe(true); + }); + + it("handles drag and drop", async () => { + const onUpload = jest.fn().mockResolvedValue("https://example.com/dropped.png"); + const onChange = jest.fn(); + + render( + + ); + + const dropzone = screen.getByText("Click or drag to upload").closest( + "[class*='dropzone']" + ) as HTMLElement; + + const file = createFile(); + + fireEvent.dragOver(dropzone, { dataTransfer: { files: [file] } }); + fireEvent.drop(dropzone, { dataTransfer: { files: [file] } }); + + await waitFor(() => { + expect(onUpload).toHaveBeenCalledWith(file); + expect(onChange).toHaveBeenCalledWith("https://example.com/dropped.png"); + }); + }); +}); diff --git a/packages/core/components/AutoField/fields/ImageField/index.tsx b/packages/core/components/AutoField/fields/ImageField/index.tsx new file mode 100644 index 0000000000..24803f4be9 --- /dev/null +++ b/packages/core/components/AutoField/fields/ImageField/index.tsx @@ -0,0 +1,189 @@ +import { useCallback, useRef, useState } from "react"; +import type { FieldPropsInternal } from "../.."; +import type { ImageField as ImageFieldType } from "../../../../types"; +import { Image, Trash2, Upload } from "lucide-react"; +import { useDeepField } from "../../lib/use-deep-field"; +import { useLocalValue } from "../../lib/use-local-value"; +import getClassNameFactory from "../../../../lib/get-class-name-factory"; + +import styles from "./styles.module.css"; + +const getClassName = getClassNameFactory("ImageField", styles); + +export const ImageField = ({ + field, + onChange, + id, + name = id, + label, + labelIcon, + Label, + readOnly, +}: FieldPropsInternal) => { + const value = useDeepField(name) as string | undefined; + const [localUrl, onChangeLocal] = useLocalValue(name, onChange); + const fileInputRef = useRef(null); + const [isLoading, setIsLoading] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const [error, setError] = useState(null); + + if (field.type !== "image") { + return null; + } + + const imageField = field as ImageFieldType; + + const handleFile = useCallback( + async (file: File) => { + setError(null); + + if (imageField.validate) { + const validationError = imageField.validate(file); + if (validationError) { + setError(validationError); + return; + } + } + + setIsLoading(true); + + try { + const url = await imageField.onUpload(file); + onChange(url); + } catch (e) { + setError(e instanceof Error ? e.message : "Upload failed"); + } finally { + setIsLoading(false); + } + }, + [imageField, onChange] + ); + + const handleFileInputChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + handleFile(file); + } + + // Reset so the same file can be re-selected + e.target.value = ""; + }, + [handleFile] + ); + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + if (!readOnly && !isLoading) { + setIsDragOver(true); + } + }, + [readOnly, isLoading] + ); + + const handleDragLeave = useCallback(() => { + setIsDragOver(false); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + + if (readOnly || isLoading) return; + + const file = e.dataTransfer.files?.[0]; + if (file) { + handleFile(file); + } + }, + [readOnly, isLoading, handleFile] + ); + + const handleRemove = useCallback(() => { + onChange(""); + setError(null); + }, [onChange]); + + const handleDropzoneClick = useCallback(() => { + if (!readOnly && !isLoading) { + fileInputRef.current?.click(); + } + }, [readOnly, isLoading]); + + return ( + + ); +}; diff --git a/packages/core/components/AutoField/fields/ImageField/styles.module.css b/packages/core/components/AutoField/fields/ImageField/styles.module.css new file mode 100644 index 0000000000..06868b82bd --- /dev/null +++ b/packages/core/components/AutoField/fields/ImageField/styles.module.css @@ -0,0 +1,132 @@ +/** + * ImageField + */ + +.ImageField { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ImageField-dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 24px 16px; + border: 2px dashed var(--puck-color-grey-09); + border-radius: 4px; + background: var(--puck-color-azure-11); + cursor: pointer; + color: var(--puck-color-grey-05); + font-size: 14px; + transition: border-color 50ms ease-in, background-color 50ms ease-in; +} + +.ImageField-dropzone:hover { + border-color: var(--puck-color-azure-05); + background: var(--puck-color-azure-10); +} + +.ImageField-dropzone--isDragOver { + border-color: var(--puck-color-azure-05); + background: var(--puck-color-azure-10); +} + +.ImageField-dropzone--isReadOnly { + cursor: default; + opacity: 0.6; +} + +.ImageField-dropzone--isReadOnly:hover { + border-color: var(--puck-color-grey-09); + background: var(--puck-color-azure-11); +} + +.ImageField-dropzone--isLoading { + cursor: wait; +} + +.ImageField-fileInput { + display: none; +} + +.ImageField-urlInput { + background: var(--puck-color-white); + border-width: 1px; + border-style: solid; + border-color: var(--puck-color-grey-09); + border-radius: 4px; + box-sizing: border-box; + font-family: inherit; + font-size: 16px; + padding: 12px 15px; + transition: border-color 50ms ease-in; + width: 100%; + max-width: 100%; +} + +@media (min-width: 458px) { + .ImageField-urlInput { + font-size: 14px; + } +} + +.ImageField-urlInput:focus { + border-color: var(--puck-color-grey-05); + outline: 2px solid var(--puck-color-azure-05); + transition: none; +} + +.ImageField-urlInput[readonly] { + background-color: var(--puck-color-grey-11); + border-color: var(--puck-color-grey-09); + color: var(--puck-color-grey-04); + cursor: default; + opacity: 1; + outline: 0; + transition: none; +} + +.ImageField-preview { + position: relative; + border: 1px solid var(--puck-color-grey-09); + border-radius: 4px; + overflow: hidden; +} + +.ImageField-previewImage { + display: block; + width: 100%; + max-height: 200px; + object-fit: contain; + background: var(--puck-color-azure-11); +} + +.ImageField-removeButton { + position: absolute; + top: 8px; + right: 8px; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 4px; + background: var(--puck-color-grey-01); + color: var(--puck-color-white); + cursor: pointer; + opacity: 0.8; + transition: opacity 50ms ease-in; +} + +.ImageField-removeButton:hover { + opacity: 1; +} + +.ImageField-error { + color: var(--puck-color-red-03); + font-size: 12px; +} diff --git a/packages/core/components/AutoField/fields/index.tsx b/packages/core/components/AutoField/fields/index.tsx index 76a51ab2e9..9b338a0fd5 100644 --- a/packages/core/components/AutoField/fields/index.tsx +++ b/packages/core/components/AutoField/fields/index.tsx @@ -1,6 +1,7 @@ export * from "./ArrayField"; export * from "./DefaultField"; export * from "./ExternalField"; +export * from "./ImageField"; export * from "./RadioField"; export * from "./SelectField"; export * from "./TextareaField"; diff --git a/packages/core/components/AutoField/index.tsx b/packages/core/components/AutoField/index.tsx index ba83404bcb..eb5fc928d8 100644 --- a/packages/core/components/AutoField/index.tsx +++ b/packages/core/components/AutoField/index.tsx @@ -14,6 +14,7 @@ import { RadioField, SelectField, ExternalField, + ImageField, ArrayField, DefaultField, TextareaField, @@ -51,6 +52,7 @@ export { FieldLabel } from "./FieldLabel"; const defaultFields = { array: ArrayField, external: ExternalField, + image: ImageField, object: ObjectField, select: SelectField, textarea: TextareaField, @@ -88,6 +90,7 @@ function AutoFieldInternal< custom: overrides.fieldTypes?.custom, array: overrides.fieldTypes?.array || defaultFields.array, external: overrides.fieldTypes?.external || defaultFields.external, + image: overrides.fieldTypes?.image || defaultFields.image, object: overrides.fieldTypes?.object || defaultFields.object, select: overrides.fieldTypes?.select || defaultFields.select, textarea: overrides.fieldTypes?.textarea || defaultFields.textarea, diff --git a/packages/core/types/Fields.ts b/packages/core/types/Fields.ts index af8f4d47e4..c0d7e27b17 100644 --- a/packages/core/types/Fields.ts +++ b/packages/core/types/Fields.ts @@ -167,6 +167,13 @@ export interface CustomField extends BaseField { key?: string; } +export interface ImageField extends BaseField { + type: "image"; + onUpload: (file: File) => Promise; + validate?: (file: File) => string | null; + placeholder?: string; +} + export interface SlotField extends BaseField { type: "slot"; allow?: string[]; @@ -188,6 +195,7 @@ export type Field = | ExternalField | ExternalFieldWithAdaptor | CustomField + | ImageField | SlotField; export type Fields<