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) +}