diff --git a/packages/jsonforms-renderers/dev/fixtures.ts b/packages/jsonforms-renderers/dev/fixtures.ts
index 777103c..3d526fb 100644
--- a/packages/jsonforms-renderers/dev/fixtures.ts
+++ b/packages/jsonforms-renderers/dev/fixtures.ts
@@ -452,6 +452,16 @@ export const fixtures: Fixture[] = [
items: { type: 'string', format: 'file' },
description: 'Multiple files',
},
+ namedAttachments: {
+ type: 'array',
+ items: { type: 'string', format: 'file' },
+ description: 'Multi-file with named Download (View only for browser-viewable types)',
+ 'x-file': {
+ maxFiles: 5,
+ accept: 'image/png,image/jpeg,application/pdf,.xlsx,.png,.jpg,.jpeg,.pdf',
+ fileName: 'Attachment_{index}',
+ },
+ },
},
},
uischema: {
@@ -459,6 +469,7 @@ export const fixtures: Fixture[] = [
elements: [
{ type: 'Control', scope: '#/properties/avatar' },
{ type: 'Control', scope: '#/properties/attachments' },
+ { type: 'Control', scope: '#/properties/namedAttachments' },
],
} as UISchemaElement,
},
diff --git a/packages/jsonforms-renderers/docs/file-control.md b/packages/jsonforms-renderers/docs/file-control.md
new file mode 100644
index 0000000..3ccf12d
--- /dev/null
+++ b/packages/jsonforms-renderers/docs/file-control.md
@@ -0,0 +1,75 @@
+# File upload (`x-file` / `format: "file"`)
+
+`FileControl` is a `Control` renderer for uploading files into form data. The bound field stores one storage key (`type: "string"`) or an array of keys (`type: "array"` of strings), not the file bytes. Upload and download URLs come from `UploadProvider` in the host app.
+
+## What triggers it
+
+The bound schema node must use `format: "file"`:
+
+- `type: "string"` — single file (default `maxFiles: 1`)
+- `type: "array"` with `items: { type: "string", format: "file" }` — multi-file when `x-file.maxFiles` is greater than 1
+
+`FileControlTester` ranks 10 so it beats the default string and array renderers.
+
+```jsonc
+// schema
+{
+ "type": "object",
+ "properties": {
+ "attachment": {
+ "type": "string",
+ "format": "file",
+ "x-file": {
+ "accept": "image/*,application/pdf,.xlsx",
+ "fileName": "Attachment_{index}",
+ },
+ },
+ },
+}
+```
+
+```jsonc
+// uischema
+{ "type": "Control", "scope": "#/properties/attachment" }
+```
+
+## Options
+
+| Option | Type | Default | Meaning |
+| ---------- | -------- | --------------------------- | ------------------------------------------------------------------------ |
+| `maxFiles` | `number` | `1` | Maximum uploads. Greater than 1 requires an array schema. |
+| `maxSize` | `number` | `5242880` (5 MiB) | Per-file size limit in bytes. |
+| `accept` | `string` | `'image/*,application/pdf'` | Comma-separated MIME types, wildcards, and/or extensions (e.g. `.xlsx`). |
+| `fileName` | `string` | _(none)_ | Base name for the row label and Download — see [File name](#file-name). |
+
+`maxFiles`, `maxSize` and `accept` may also be set under `uischema.options` for older forms; schema `x-file` wins when both are present. `fileName` is read from `x-file` only.
+
+## File name
+
+`fileName` is a **base name** template. The file's real extension (from the original name, else the storage key) is appended unless the rendered name already ends with that extension.
+
+```jsonc
+"fileName": "Attachment_{index}" // Attachment_1.xlsx for the first .xlsx upload
+```
+
+- `{index}` is the file's **1-based position** in the field. Removing an earlier file renumbers later ones (`Attachment_2` becomes `Attachment_1`).
+- Other placeholders follow the same `{name}` / `{today(…)}` rules as export `fileName`, but only `{index}` is supplied today (form values and `{seq(…)}` are a follow-up).
+- Unsafe characters in the rendered name become `_`.
+
+Without `fileName`, the row label and Download use the original upload name when known, otherwise the storage key.
+
+## View and Download
+
+Each uploaded row has:
+
+- **View** — only when the file matches the browser-viewable allowlist (common images, PDF, HTML, plain text). Opens in a new tab (`window.open`). Types such as `.xlsx` do not show View.
+- **Download** — on every row. Fetches the file and saves it under the name from [File name](#file-name).
+
+For a just-uploaded file, both actions use the local blob URL. For a saved form that only has a storage key, they call `UploadProvider.getDownloadUrl`.
+
+## Behavior notes
+
+- Upload failures show in the drop zone. View/Download failures show as a per-row message under that row (so they remain visible when the control is read-only or the single-file slot is full), and clear on the next attempt.
+- **Download fetches the signed storage URL in the browser**, so the storage bucket must allow CORS `GET` from the app origin. Confirm that for each app using this package; it is outside this package.
+- Removing the last file clears the field with `undefined` (not `null`) so a `type: "string"` schema stays valid.
+- The control renders nothing when `visible` is `false`. When read-only with no files, it also renders nothing.
diff --git a/packages/jsonforms-renderers/src/renderers/FileControl.test.tsx b/packages/jsonforms-renderers/src/renderers/FileControl.test.tsx
index 3593653..25d86cd 100644
--- a/packages/jsonforms-renderers/src/renderers/FileControl.test.tsx
+++ b/packages/jsonforms-renderers/src/renderers/FileControl.test.tsx
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
-import { afterEach, beforeAll, describe, expect, it } from 'vitest'
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { useState } from 'react'
-import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { JsonForms } from '@jsonforms/react'
import { Theme } from '@radix-ui/themes'
import type { JsonSchema, UISchemaElement } from '@jsonforms/core'
@@ -27,6 +27,8 @@ afterEach(() => {
for (const stop of finishers) stop()
finishers.length = 0
cleanup()
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
})
const schema: JsonSchema = {
@@ -41,27 +43,84 @@ const uischema = {
elements: [{ type: 'Control', scope: '#/properties/attachment' }],
} as UISchemaElement
-function Harness({ seed, onData }: { seed: Data; onData: (data: Data) => void }) {
+const namedSchema = {
+ type: 'object',
+ properties: {
+ attachment: {
+ type: 'string',
+ format: 'file',
+ 'x-file': { fileName: 'Attachment_{index}', accept: 'image/*,application/pdf,.xlsx,.html' },
+ },
+ },
+} as unknown as JsonSchema
+
+const multiSchema = {
+ type: 'object',
+ properties: {
+ files: {
+ type: 'array',
+ items: { type: 'string', format: 'file' },
+ 'x-file': {
+ maxFiles: 5,
+ fileName: 'Attachment_{index}',
+ accept: 'image/png,application/pdf,text/html,.xlsx,.png,.pdf,.html',
+ },
+ },
+ },
+} as unknown as JsonSchema
+
+const multiUi = {
+ type: 'VerticalLayout',
+ elements: [{ type: 'Control', scope: '#/properties/files' }],
+} as UISchemaElement
+
+function Harness({
+ seed,
+ onData,
+ schema: formSchema = schema,
+ uischema: formUi = uischema,
+ enabled = true,
+ getDownloadUrl,
+}: {
+ seed: Data
+ onData: (data: Data) => void
+ schema?: JsonSchema
+ uischema?: UISchemaElement
+ enabled?: boolean
+ getDownloadUrl?: (key: string) => Promise<{ url: string; expiresAt: number }>
+}) {
const [initial] = useState(seed)
// Theme for the same reason SpreadsheetControl's harness needs it (Radix
// Tooltip/IconButton throw without a provider); UploadProvider supplies the
// upload this control needs before there is a file to remove.
return (
- Promise.resolve({ key: file.name })}>
+ Promise.resolve({ key: file.name, name: file.name })}
+ getDownloadUrl={getDownloadUrl}
+ >
onData(data as Data)}
+ readonly={!enabled}
/>
)
}
-function renderForm(seed: Data) {
+function renderForm(
+ seed: Data,
+ options: {
+ schema?: JsonSchema
+ uischema?: UISchemaElement
+ enabled?: boolean
+ getDownloadUrl?: (key: string) => Promise<{ url: string; expiresAt: number }>
+ } = {},
+) {
const writes: Data[] = []
let live = true
finishers.push(() => {
@@ -70,6 +129,10 @@ function renderForm(seed: Data) {
render(
{
if (live) writes.push(data)
}}
@@ -78,21 +141,21 @@ function renderForm(seed: Data) {
return { writes }
}
-function uploadFile(name: string) {
+function uploadFile(name: string, type: string) {
const input = document.querySelector('input[type=file]')
if (!input) throw new Error('no file input')
- fireEvent.change(input, { target: { files: [new File(['contents'], name, { type: 'application/pdf' })] } })
+ fireEvent.change(input, { target: { files: [new File(['contents'], name, { type })] } })
}
describe('FileControl removing the last file', () => {
it('removes the key entirely instead of leaving null behind', async () => {
const { writes } = renderForm({})
- uploadFile('doc.pdf')
+ uploadFile('doc.pdf', 'application/pdf')
await waitFor(() => expect(writes[writes.length - 1]?.attachment).toBe('doc.pdf'))
// Single-file mode (default maxFiles: 1) hides the drop zone once
- // uploaded, so exactly two buttons remain: "View" and this remove icon.
+ // uploaded, so the last control button is the remove icon.
const buttons = [...document.querySelectorAll('button')]
fireEvent.click(buttons[buttons.length - 1])
@@ -104,3 +167,145 @@ describe('FileControl removing the last file', () => {
expect('attachment' in writes[writes.length - 1]).toBe(false)
})
})
+
+describe('FileControl View and Download', () => {
+ beforeEach(() => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ blob: () => Promise.resolve(new Blob(['sheet'])),
+ }),
+ )
+ })
+
+ it('hides View for xlsx and shows it for png, pdf and html keys', () => {
+ renderForm({ files: ['a.xlsx', 'b.png', 'c.pdf', 'd.html'] }, { schema: multiSchema, uischema: multiUi })
+
+ const viewButtons = screen.queryAllByRole('button', { name: 'View' })
+ expect(viewButtons).toHaveLength(3)
+ expect(screen.getByText('Attachment_1.xlsx')).toBeTruthy()
+ expect(screen.getByText('Attachment_2.png')).toBeTruthy()
+ expect(screen.getByText('Attachment_3.pdf')).toBeTruthy()
+ expect(screen.getByText('Attachment_4.html')).toBeTruthy()
+ })
+
+ it('downloads through the local blob under the template name with the real extension', async () => {
+ const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
+ renderForm({}, { schema: namedSchema })
+
+ uploadFile('report.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
+ await waitFor(() => expect(screen.getByText('Attachment_1.xlsx')).toBeTruthy())
+
+ fireEvent.click(screen.getByRole('button', { name: 'Download' }))
+
+ await waitFor(() => {
+ expect(fetch).toHaveBeenCalledWith('blob:mock')
+ expect(click).toHaveBeenCalled()
+ })
+ const anchors = click.mock.instances as unknown as HTMLAnchorElement[]
+ expect(anchors.some((a) => a.download === 'Attachment_1.xlsx')).toBe(true)
+ })
+
+ it('downloads through getDownloadUrl for a saved form', async () => {
+ const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
+ const getDownloadUrl = vi.fn().mockResolvedValue({
+ url: 'https://storage.example/signed',
+ expiresAt: Date.now() + 60_000,
+ })
+ renderForm({ attachment: '0f8e.xlsx' }, { schema: namedSchema, getDownloadUrl })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Download' }))
+
+ await waitFor(() => {
+ expect(getDownloadUrl).toHaveBeenCalledWith('0f8e.xlsx')
+ expect(fetch).toHaveBeenCalledWith('https://storage.example/signed')
+ expect(click).toHaveBeenCalled()
+ })
+ const anchors = click.mock.instances as unknown as HTMLAnchorElement[]
+ expect(anchors.some((a) => a.download === 'Attachment_1.xlsx')).toBe(true)
+ })
+
+ it('falls back to the original name when there is no template', async () => {
+ const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
+ renderForm({})
+
+ uploadFile('report.pdf', 'application/pdf')
+ await waitFor(() => expect(screen.getByText('report.pdf')).toBeTruthy())
+
+ fireEvent.click(screen.getByRole('button', { name: 'Download' }))
+
+ await waitFor(() => expect(click).toHaveBeenCalled())
+ const anchors = click.mock.instances as unknown as HTMLAnchorElement[]
+ expect(anchors.some((a) => a.download === 'report.pdf')).toBe(true)
+ })
+
+ it('shows a row error for a failed Download when read-only', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403 }))
+ const getDownloadUrl = vi.fn().mockResolvedValue({
+ url: 'https://storage.example/signed',
+ expiresAt: Date.now() + 60_000,
+ })
+ renderForm({ attachment: 'doc.pdf' }, { schema: namedSchema, enabled: false, getDownloadUrl })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Download' }))
+
+ await waitFor(() => expect(screen.getByText('Unable to download file.')).toBeTruthy())
+ })
+
+ it('shows a row error for a failed Download when the single file slot is full', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')))
+ renderForm({}, { schema: namedSchema })
+
+ uploadFile('doc.pdf', 'application/pdf')
+ await waitFor(() => expect(screen.getByText('Attachment_1.pdf')).toBeTruthy())
+ expect(document.querySelector('input[type=file]')).toBeNull()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Download' }))
+
+ await waitFor(() => expect(screen.getByText('Unable to download file.')).toBeTruthy())
+ })
+
+ it('still opens View with window.open', async () => {
+ const opened = { focus: vi.fn() }
+ const open = vi.spyOn(window, 'open').mockReturnValue(opened as unknown as Window)
+
+ renderForm({}, { schema: namedSchema })
+ uploadFile('photo.png', 'image/png')
+ await waitFor(() => expect(screen.getByRole('button', { name: 'View' })).toBeTruthy())
+
+ fireEvent.click(screen.getByRole('button', { name: 'View' }))
+
+ expect(open).toHaveBeenCalledWith('blob:mock', '_blank', 'noopener,noreferrer')
+ })
+
+ it('opens View through getDownloadUrl for a saved form', async () => {
+ const opened = { location: { href: '' }, close: vi.fn() }
+ const open = vi.spyOn(window, 'open').mockReturnValue(opened as unknown as Window)
+ const getDownloadUrl = vi.fn().mockResolvedValue({
+ url: 'https://storage.example/signed',
+ expiresAt: Date.now() + 60_000,
+ })
+ renderForm({ attachment: 'photo.png' }, { schema: namedSchema, getDownloadUrl })
+
+ fireEvent.click(screen.getByRole('button', { name: 'View' }))
+
+ await waitFor(() => {
+ expect(getDownloadUrl).toHaveBeenCalledWith('photo.png')
+ expect(opened.location.href).toBe('https://storage.example/signed')
+ })
+ expect(open).toHaveBeenCalledWith('', '_blank')
+ })
+
+ it('shows a row error when View cannot resolve the link', async () => {
+ const opened = { location: { href: '' }, close: vi.fn() }
+ vi.spyOn(window, 'open').mockReturnValue(opened as unknown as Window)
+ const getDownloadUrl = vi.fn().mockResolvedValue(undefined)
+ renderForm({ attachment: 'photo.png' }, { schema: namedSchema, getDownloadUrl })
+
+ fireEvent.click(screen.getByRole('button', { name: 'View' }))
+
+ await waitFor(() => expect(screen.getByText('Unable to open file.')).toBeTruthy())
+ expect(opened.close).toHaveBeenCalled()
+ })
+})
diff --git a/packages/jsonforms-renderers/src/renderers/FileControl.tsx b/packages/jsonforms-renderers/src/renderers/FileControl.tsx
index fc16b81..c96ac5f 100644
--- a/packages/jsonforms-renderers/src/renderers/FileControl.tsx
+++ b/packages/jsonforms-renderers/src/renderers/FileControl.tsx
@@ -1,17 +1,27 @@
import { withJsonFormsControlProps } from '@jsonforms/react'
import type { ControlElement, JsonSchema } from '@jsonforms/core'
-import { Card, Flex, Text, Box, IconButton, Button } from '@radix-ui/themes'
-import { UploadIcon, FileTextIcon, Cross2Icon, CheckCircledIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons'
+import { Card, Flex, Text, Box, IconButton, Button, Tooltip } from '@radix-ui/themes'
+import {
+ UploadIcon,
+ FileTextIcon,
+ Cross2Icon,
+ CheckCircledIcon,
+ ExclamationTriangleIcon,
+ DownloadIcon,
+} from '@radix-ui/react-icons'
import { useState, useRef, useEffect, useCallback, type ChangeEvent, type DragEvent } from 'react'
import { useUpload } from '../contexts/UploadContext'
import { getErrorMessage } from '../utils/error'
import { formatBytes, formatAccept } from '../utils/format'
import { useClearWhenHidden } from '../hooks/useClearWhenHidden'
+import { downloadFromUrl, downloadName } from '../utils/download'
+import { isBrowserViewable, matchesAccept } from '../utils/file'
import * as React from 'react'
interface FileEntry {
key: string
name: string
+ type?: string
blobUrl?: string
}
@@ -19,6 +29,14 @@ interface XFileOptions {
maxFiles?: number
maxSize?: number
accept?: string
+ /**
+ * Base name for the row label and Download. `{index}` is the file's 1-based
+ * position (removing an earlier file renumbers later ones). The file's real
+ * extension is appended. e.g. `Attachment_{index}`. Download fetches the
+ * signed storage URL in the browser, so the bucket must allow CORS GET from
+ * the app origin.
+ */
+ fileName?: string
}
interface FileControlProps {
@@ -68,12 +86,14 @@ const FileControl = ({
const maxFiles = xFile.maxFiles ?? (uiOptions.maxFiles as number) ?? 1
const maxSize = xFile.maxSize ?? (uiOptions.maxSize as number) ?? 5 * 1024 * 1024
const accept = xFile.accept ?? (uiOptions.accept as string) ?? 'image/*,application/pdf'
+ const fileNameTemplate = typeof xFile.fileName === 'string' ? xFile.fileName : undefined
const isMulti = maxFiles > 1
const isEnabled = enabled !== false
const [dragActive, setDragActive] = useState(false)
const [error, setError] = useState(null)
+ const [rowErrors, setRowErrors] = useState>({})
const [fileEntries, setFileEntries] = useState>({})
const activeBlobs = useRef>(new Set())
const inputRef = useRef(null)
@@ -81,6 +101,31 @@ const FileControl = ({
const currentKeys = normalizeData(data)
const atLimit = currentKeys.length >= maxFiles
+ const fileForViewability = (key: string) => {
+ const entry = fileEntries[key]
+ return { name: entry?.name ?? key, type: entry?.type }
+ }
+
+ const resolveFileUrl = async (key: string): Promise => {
+ const blobUrl = fileEntries[key]?.blobUrl
+ if (blobUrl) return blobUrl
+ const result = await uploadContext?.getDownloadUrl?.(key)
+ return result?.url
+ }
+
+ const setRowError = (key: string, message: string) => {
+ setRowErrors((prev) => ({ ...prev, [key]: message }))
+ }
+
+ const clearRowError = (key: string) => {
+ setRowErrors((prev) => {
+ if (!prev[key]) return prev
+ const next = { ...prev }
+ delete next[key]
+ return next
+ })
+ }
+
useEffect(() => {
return () => {
activeBlobs.current.forEach((url) => URL.revokeObjectURL(url))
@@ -101,14 +146,7 @@ const FileControl = ({
return
}
- const acceptedTypes = accept.split(',').map((t) => t.trim())
- const typeOk = acceptedTypes.some((type) => {
- if (type === '*' || type === '*/*') return true
- if (type.endsWith('/*')) return file.type.startsWith(type.slice(0, -1))
- if (type.startsWith('.')) return file.name.toLowerCase().endsWith(type.toLowerCase())
- return file.type === type
- })
- if (!typeOk) {
+ if (!matchesAccept(file, accept)) {
setError(`Invalid type. Accepted: ${formatAccept(accept)}`)
return
}
@@ -122,7 +160,12 @@ const FileControl = ({
const result = await uploadContext.onUpload(file)
const blobUrl = URL.createObjectURL(file)
activeBlobs.current.add(blobUrl)
- const entry: FileEntry = { key: result.key, name: result.name ?? file.name, blobUrl }
+ const entry: FileEntry = {
+ key: result.key,
+ name: result.name ?? file.name,
+ type: file.type || undefined,
+ blobUrl,
+ }
setFileEntries((prev) => ({ ...prev, [result.key]: entry }))
@@ -152,12 +195,12 @@ const FileControl = ({
e.stopPropagation()
setDragActive(false)
if (!isEnabled || atLimit) return
- if (e.dataTransfer.files?.[0]) processFile(e.dataTransfer.files[0])
+ if (e.dataTransfer.files?.[0]) void processFile(e.dataTransfer.files[0])
}
const handleInputChange = (e: ChangeEvent) => {
if (e.target.files?.[0]) {
- processFile(e.target.files[0])
+ void processFile(e.target.files[0])
e.target.value = ''
}
}
@@ -174,6 +217,7 @@ const FileControl = ({
delete next[key]
return next
})
+ clearRowError(key)
const newKeys = currentKeys.filter((k) => k !== key)
// `undefined`, not `null` — same reason as the clear-when-hidden call
// above: `null` doesn't satisfy this field's `type: 'string'` schema, so
@@ -185,19 +229,40 @@ const FileControl = ({
const onView = async (e: React.MouseEvent, key: string) => {
e.preventDefault()
+ clearRowError(key)
+
const blobUrl = fileEntries[key]?.blobUrl
if (blobUrl) {
window.open(blobUrl, '_blank', 'noopener,noreferrer')?.focus()
return
}
+ // Open synchronously so the popup blocker does not block the tab.
const newWindow = window.open('', '_blank')
if (!newWindow) return
try {
- const result = await uploadContext?.getDownloadUrl?.(key)
- if (result?.url) newWindow.location.href = result.url
- else newWindow.close()
+ const url = await resolveFileUrl(key)
+ if (url) newWindow.location.href = url
+ else {
+ newWindow.close()
+ setRowError(key, 'Unable to open file.')
+ }
} catch {
newWindow.close()
+ setRowError(key, 'Unable to open file.')
+ }
+ }
+
+ const onDownload = async (key: string, name: string) => {
+ clearRowError(key)
+ try {
+ const url = await resolveFileUrl(key)
+ if (!url) {
+ setRowError(key, 'Unable to download file.')
+ return
+ }
+ await downloadFromUrl(url, name)
+ } catch {
+ setRowError(key, 'Unable to download file.')
}
}
@@ -222,48 +287,89 @@ const FileControl = ({
{/* ── Uploaded file rows ── */}
- {currentKeys.map((key) => (
-
-
-
-
-
-
- {
+ const index = position + 1
+ const originalName = fileEntries[key]?.name
+ const name = downloadName(fileNameTemplate, { index }, { name: originalName, key })
+ const labelText = fileNameTemplate ? name : (originalName ?? 'Uploaded File')
+ const showSecondary = Boolean(fileNameTemplate && originalName)
+ const rowError = rowErrors[key]
+ const canView = isBrowserViewable(fileForViewability(key))
+
+ return (
+
+
+
- {fileEntries[key]?.name ?? 'Uploaded File'}
-
-
-
-
-
- {isEnabled && (
- handleRemove(key)}>
-
-
- )}
+
+
+
+
+ {labelText}
+
+ {showSecondary && (
+
+ {originalName}
+
+ )}
+
+
+ {canView && (
+
+ )}
+
+ void onDownload(key, name)}
+ >
+
+
+
+
+ {isEnabled && (
+ handleRemove(key)}>
+
+
+ )}
+
-
-
- ))}
+ {rowError && (
+
+ {rowError}
+
+ )}
+
+ )
+ })}
{/* ── Drop zone — hidden once limit reached ── */}
{showDropZone && (
diff --git a/packages/jsonforms-renderers/src/utils/download.test.ts b/packages/jsonforms-renderers/src/utils/download.test.ts
index 714772f..b4d2ab9 100644
--- a/packages/jsonforms-renderers/src/utils/download.test.ts
+++ b/packages/jsonforms-renderers/src/utils/download.test.ts
@@ -1,6 +1,13 @@
// @vitest-environment jsdom
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { downloadTextFile, renderFileName, sanitizeFileName } from './download'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ downloadBlob,
+ downloadFromUrl,
+ downloadName,
+ downloadTextFile,
+ renderFileName,
+ sanitizeFileName,
+} from './download'
// jsdom implements Blob but not URL.createObjectURL/revokeObjectURL at all, so
// what's verified here is the CONTRACT — a blob URL is created from the given
@@ -8,16 +15,25 @@ import { downloadTextFile, renderFileName, sanitizeFileName } from './download'
// is revoked afterwards — not an actual browser download. Stubbed as plain
// assignments (not vi.spyOn, which requires the property to already exist)
// and removed again afterwards rather than restored, for the same reason.
+//
+// downloadBlob defers revoke with setTimeout(0). Tests that trigger a download
+// use fake timers so afterEach can flush that timeout before deleting stubs.
afterEach(() => {
+ if (vi.isFakeTimers()) vi.runAllTimers()
// @ts-expect-error -- jsdom doesn't declare these; see the stub note above.
delete URL.createObjectURL
// @ts-expect-error -- ditto.
delete URL.revokeObjectURL
vi.restoreAllMocks()
+ vi.useRealTimers()
})
describe('downloadTextFile', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
it('creates an object URL from the content and mime type given', () => {
const createObjectURL = vi.fn().mockReturnValue('blob:mock-url')
URL.createObjectURL = createObjectURL
@@ -55,10 +71,92 @@ describe('downloadTextFile', () => {
downloadTextFile('1', 'a.xml', 'application/xml')
+ expect(revokeObjectURL).not.toHaveBeenCalled()
+ vi.runAllTimers()
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
})
})
+describe('downloadBlob', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ it('clicks an anchor with the given download name for an existing blob', () => {
+ URL.createObjectURL = vi.fn().mockReturnValue('blob:from-blob')
+ URL.revokeObjectURL = vi.fn()
+ const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
+ expect(this.href).toBe('blob:from-blob')
+ expect(this.download).toBe('Attachment_1.xlsx')
+ })
+
+ downloadBlob(new Blob(['sheet'], { type: 'application/vnd.ms-excel' }), 'Attachment_1.xlsx')
+
+ expect(click).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('downloadFromUrl', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('downloads the response blob under the given name', async () => {
+ const blob = new Blob(['sheet'])
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) }))
+ URL.createObjectURL = vi.fn().mockReturnValue('blob:from-url')
+ URL.revokeObjectURL = vi.fn()
+ const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
+ expect(this.download).toBe('Attachment_1.xlsx')
+ })
+
+ await downloadFromUrl('https://example.test/file', 'Attachment_1.xlsx')
+
+ expect(fetch).toHaveBeenCalledWith('https://example.test/file')
+ expect(click).toHaveBeenCalledTimes(1)
+ })
+
+ it('throws on a non-OK response', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403 }))
+ await expect(downloadFromUrl('https://example.test/file', 'a.xlsx')).rejects.toThrow(/403/)
+ })
+
+ it('throws on a network error', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')))
+ await expect(downloadFromUrl('https://example.test/file', 'a.xlsx')).rejects.toThrow('offline')
+ })
+})
+
+describe('downloadName', () => {
+ it('renders a template and appends the real extension', () => {
+ expect(downloadName('Attachment_{index}', { index: 1 }, { name: 'report.xlsx', key: 'k1' })).toBe(
+ 'Attachment_1.xlsx',
+ )
+ })
+
+ it('does not double the extension when the template already ends with it', () => {
+ expect(downloadName('Attachment_{index}.xlsx', { index: 2 }, { name: 'report.xlsx', key: 'k1' })).toBe(
+ 'Attachment_2.xlsx',
+ )
+ })
+
+ it('falls back to the original name when there is no template', () => {
+ expect(downloadName(undefined, { index: 1 }, { name: 'report.xlsx', key: 'k1' })).toBe('report.xlsx')
+ })
+
+ it('falls back to the key when there is no template and no name', () => {
+ expect(downloadName(undefined, { index: 1 }, { key: '0f8e.xlsx' })).toBe('0f8e.xlsx')
+ })
+
+ it('takes the extension from the key when the original name is missing', () => {
+ expect(downloadName('Attachment_{index}', { index: 1 }, { key: '0f8e.xlsx' })).toBe('Attachment_1.xlsx')
+ })
+})
+
describe('sanitizeFileName', () => {
it('replaces path separators, so a value like 121/2026 is not read as a folder', () => {
expect(sanitizeFileName('blend_121/2026.xml', 'export.xml')).toBe('blend_121_2026.xml')
diff --git a/packages/jsonforms-renderers/src/utils/download.ts b/packages/jsonforms-renderers/src/utils/download.ts
index d5dc3c1..f344f18 100644
--- a/packages/jsonforms-renderers/src/utils/download.ts
+++ b/packages/jsonforms-renderers/src/utils/download.ts
@@ -1,3 +1,4 @@
+import { fileExtension } from './file'
import { renderTemplate } from './template'
// Shared browser-download primitive: a Blob plus a synthetic anchor click,
@@ -5,13 +6,47 @@ import { renderTemplate } from './template'
// reimplement. Kept separate from any one renderer so a second "download this
// as text" control (see XmlExportControl) never has to duplicate it.
export function downloadTextFile(content: string, fileName: string, mimeType: string): void {
- const blob = new Blob([content], { type: mimeType })
+ downloadBlob(new Blob([content], { type: mimeType }), fileName)
+}
+
+// Trigger a browser download from an already-fetched Blob under a chosen name.
+// Revoke on the next turn so Safari/Firefox can start the download first;
+// revoking synchronously after click() can cancel larger files.
+export function downloadBlob(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
- URL.revokeObjectURL(url)
+ setTimeout(() => URL.revokeObjectURL(url), 0)
+}
+
+// Fetch a URL and save it under `fileName`. Throws on network failure or a
+// non-OK response so the caller can surface an error.
+export async function downloadFromUrl(url: string, fileName: string): Promise {
+ const response = await fetch(url)
+ if (!response.ok) throw new Error(`Download failed (${response.status})`)
+ downloadBlob(await response.blob(), fileName)
+}
+
+/**
+ * Name used for a file row label and Download. With a template, render it
+ * (same `{index}` / `{today(…)}` rules as export fileName) then append the
+ * file's real extension from `name`, else `key`, unless already present.
+ * Without a template, prefer the original `name`, else the storage `key`.
+ */
+export function downloadName(
+ template: string | undefined,
+ values: { index: number },
+ file: { name?: string; key: string },
+): string {
+ const fallback = file.name || file.key
+ if (!template) return fallback
+ const rendered = renderFileName(template, values, fallback)
+ const ext = fileExtension(file.name ?? '') ?? fileExtension(file.key)
+ if (!ext) return rendered
+ if (rendered.toLowerCase().endsWith(ext.toLowerCase())) return rendered
+ return `${rendered}${ext}`
}
// Path separators, the characters Windows reserves, and control characters:
diff --git a/packages/jsonforms-renderers/src/utils/file.test.ts b/packages/jsonforms-renderers/src/utils/file.test.ts
new file mode 100644
index 0000000..5411456
--- /dev/null
+++ b/packages/jsonforms-renderers/src/utils/file.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest'
+import { fileExtension, isBrowserViewable, matchesAccept } from './file'
+
+describe('matchesAccept', () => {
+ it('matches a MIME wildcard', () => {
+ expect(matchesAccept({ name: 'a.png', type: 'image/png' }, 'image/*')).toBe(true)
+ expect(matchesAccept({ name: 'a.pdf', type: 'application/pdf' }, 'image/*')).toBe(false)
+ })
+
+ it('matches an extension entry', () => {
+ expect(matchesAccept({ name: 'Report.XLSX' }, '.xlsx')).toBe(true)
+ expect(matchesAccept({ name: 'report.pdf' }, '.xlsx')).toBe(false)
+ })
+
+ it('matches an exact MIME type', () => {
+ expect(matchesAccept({ name: 'a', type: 'application/pdf' }, 'application/pdf')).toBe(true)
+ expect(matchesAccept({ name: 'a', type: 'text/plain' }, 'application/pdf')).toBe(false)
+ })
+
+ it('matches * / */*', () => {
+ expect(matchesAccept({ name: 'a.bin', type: 'application/octet-stream' }, '*')).toBe(true)
+ expect(matchesAccept({ name: 'a.bin' }, '*/*')).toBe(true)
+ })
+})
+
+describe('isBrowserViewable', () => {
+ it('allows listed MIME types', () => {
+ expect(isBrowserViewable({ name: 'a', type: 'image/png' })).toBe(true)
+ expect(isBrowserViewable({ name: 'a', type: 'application/pdf' })).toBe(true)
+ expect(isBrowserViewable({ name: 'a', type: 'text/html' })).toBe(true)
+ })
+
+ it('allows listed extensions on a storage key', () => {
+ expect(isBrowserViewable({ name: '0f8e7c1a-….png' })).toBe(true)
+ expect(isBrowserViewable({ name: 'doc.pdf' })).toBe(true)
+ })
+
+ it('rejects xlsx and TIFF', () => {
+ expect(isBrowserViewable({ name: 'sheet.xlsx' })).toBe(false)
+ expect(isBrowserViewable({ name: 'a', type: 'image/tiff' })).toBe(false)
+ })
+
+ it('treats no type and no extension as viewable', () => {
+ expect(isBrowserViewable({ name: '0f8e7c1a' })).toBe(true)
+ })
+})
+
+describe('fileExtension', () => {
+ it('returns the extension including the dot', () => {
+ expect(fileExtension('report.xlsx')).toBe('.xlsx')
+ expect(fileExtension('0f8e….PDF')).toBe('.PDF')
+ })
+
+ it('returns undefined when there is no extension', () => {
+ expect(fileExtension('readme')).toBeUndefined()
+ expect(fileExtension('.gitignore')).toBeUndefined()
+ expect(fileExtension('ends-with-dot.')).toBeUndefined()
+ })
+})
diff --git a/packages/jsonforms-renderers/src/utils/file.ts b/packages/jsonforms-renderers/src/utils/file.ts
new file mode 100644
index 0000000..f8aaac0
--- /dev/null
+++ b/packages/jsonforms-renderers/src/utils/file.ts
@@ -0,0 +1,39 @@
+// Shared file-name / MIME helpers for upload accept matching and for deciding
+// whether a browser can display a file in a tab (View) vs only save it.
+
+const VIEWABLE_ACCEPT =
+ 'image/png,image/jpeg,image/gif,image/webp,application/pdf,text/html,text/plain,.png,.jpg,.jpeg,.gif,.webp,.pdf,.html,.htm,.txt'
+
+/** True when `file` matches a comma-separated accept list (wildcards, .ext, or MIME). */
+export function matchesAccept(file: { name: string; type?: string }, accept: string): boolean {
+ const acceptedTypes = accept
+ .split(',')
+ .map((t) => t.trim())
+ .filter(Boolean)
+ return acceptedTypes.some((type) => {
+ if (type === '*' || type === '*/*') return true
+ if (type.endsWith('/*')) return (file.type ?? '').startsWith(type.slice(0, -1))
+ if (type.startsWith('.')) return file.name.toLowerCase().endsWith(type.toLowerCase())
+ return file.type === type
+ })
+}
+
+/**
+ * Types a browser can usefully open in a tab. Not `image/*`: TIFF/HEIC are not
+ * displayable in most browsers. No type and no extension stays viewable so View
+ * remains available when we only have an opaque storage key.
+ */
+export function isBrowserViewable(file: { name: string; type?: string }): boolean {
+ const hasType = Boolean(file.type)
+ const hasExt = fileExtension(file.name) !== undefined
+ if (!hasType && !hasExt) return true
+ return matchesAccept(file, VIEWABLE_ACCEPT)
+}
+
+/** `.xlsx` from `report.xlsx` or `0f8e….xlsx`; undefined when there is no extension. */
+export function fileExtension(nameOrKey: string): string | undefined {
+ const base = nameOrKey.split(/[/\\]/).pop() ?? nameOrKey
+ const dot = base.lastIndexOf('.')
+ if (dot <= 0 || dot === base.length - 1) return undefined
+ return base.slice(dot)
+}