From 3ff73e611470b97940c90d4874dc079513087543 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 6 Oct 2026 10:08:58 -0700 Subject: [PATCH 1/4] feat(jsonforms-renderers): x-file.fileName for View download naming When set, FileControl labels the row and downloads on View under that template ({index} is 1-based), so trader and officer see the same meaningful name instead of a storage key / generic label. Co-authored-by: Cursor --- packages/jsonforms-renderers/README.md | 2 +- .../src/renderers/FileControl.tsx | 44 ++++++++++++++++++- .../src/utils/download.test.ts | 17 ++++++- .../jsonforms-renderers/src/utils/download.ts | 8 +++- 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/packages/jsonforms-renderers/README.md b/packages/jsonforms-renderers/README.md index 03df5a5..39f3960 100644 --- a/packages/jsonforms-renderers/README.md +++ b/packages/jsonforms-renderers/README.md @@ -21,7 +21,7 @@ This package provides a collection of JSON Forms controls and layout renderers t - **[BooleanControl](src/renderers/BooleanControl.tsx)**: Toggle switch control for boolean values. - **[RadioControl](src/renderers/RadioControl.tsx)**: Option picker rendered as radio group buttons. - **[SelectControl](src/renderers/SelectControl.tsx)**: Select drop-down control for `enum` or `oneOf` schemas. -- **[FileControl](src/renderers/FileControl.tsx)**: File picker control supporting custom attachments. +- **[FileControl](src/renderers/FileControl.tsx)**: File picker. Optional `x-file.fileName` (e.g. `DC BAGS DETAILS _{index}.xlsx`) sets the row label and View download name. ### Layouts diff --git a/packages/jsonforms-renderers/src/renderers/FileControl.tsx b/packages/jsonforms-renderers/src/renderers/FileControl.tsx index fc16b81..d559b9f 100644 --- a/packages/jsonforms-renderers/src/renderers/FileControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/FileControl.tsx @@ -7,6 +7,7 @@ import { useUpload } from '../contexts/UploadContext' import { getErrorMessage } from '../utils/error' import { formatBytes, formatAccept } from '../utils/format' import { useClearWhenHidden } from '../hooks/useClearWhenHidden' +import { downloadBlob, renderFileName } from '../utils/download' import * as React from 'react' interface FileEntry { @@ -19,6 +20,8 @@ interface XFileOptions { maxFiles?: number maxSize?: number accept?: string + /** View/label name template; `{index}` is 1-based. e.g. `DC BAGS DETAILS _{index}.xlsx`. */ + fileName?: string } interface FileControlProps { @@ -68,10 +71,17 @@ 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 displayNameFor = (key: string, index: number): string => { + const fallback = fileEntries[key]?.name ?? 'Uploaded File' + if (!fileNameTemplate) return fallback + return renderFileName(fileNameTemplate, { index }, fallback) + } + const [dragActive, setDragActive] = useState(false) const [error, setError] = useState(null) const [fileEntries, setFileEntries] = useState>({}) @@ -185,6 +195,36 @@ const FileControl = ({ const onView = async (e: React.MouseEvent, key: string) => { e.preventDefault() + const index = currentKeys.indexOf(key) + 1 + const viewName = displayNameFor(key, index) + + // When a schema fileName is set, fetch as a Blob and download under that + // name so View works the same for trader and officer (storage URLs have no + // meaningful Content-Disposition filename). Without fileName, keep the + // previous open-in-tab behaviour. + if (fileNameTemplate) { + try { + let sourceUrl = fileEntries[key]?.blobUrl + if (!sourceUrl) { + const result = await uploadContext?.getDownloadUrl?.(key) + sourceUrl = result?.url + } + if (!sourceUrl) { + setError('Unable to open file.') + return + } + const response = await fetch(sourceUrl) + if (!response.ok) { + setError('Unable to open file.') + return + } + downloadBlob(await response.blob(), viewName) + } catch { + setError('Unable to open file.') + } + return + } + const blobUrl = fileEntries[key]?.blobUrl if (blobUrl) { window.open(blobUrl, '_blank', 'noopener,noreferrer')?.focus() @@ -222,7 +262,7 @@ const FileControl = ({ {/* ── Uploaded file rows ── */} - {currentKeys.map((key) => ( + {currentKeys.map((key, position) => ( - {fileEntries[key]?.name ?? 'Uploaded File'} + {displayNameFor(key, position + 1)} diff --git a/packages/jsonforms-renderers/src/utils/download.test.ts b/packages/jsonforms-renderers/src/utils/download.test.ts index 714772f..022a645 100644 --- a/packages/jsonforms-renderers/src/utils/download.test.ts +++ b/packages/jsonforms-renderers/src/utils/download.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { downloadTextFile, renderFileName, sanitizeFileName } from './download' +import { downloadBlob, 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 @@ -59,6 +59,21 @@ describe('downloadTextFile', () => { }) }) +describe('downloadBlob', () => { + 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('DC BAGS DETAILS _1.xlsx') + }) + + downloadBlob(new Blob(['sheet'], { type: 'application/vnd.ms-excel' }), 'DC BAGS DETAILS _1.xlsx') + + expect(click).toHaveBeenCalledTimes(1) + }) +}) + 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..525386e 100644 --- a/packages/jsonforms-renderers/src/utils/download.ts +++ b/packages/jsonforms-renderers/src/utils/download.ts @@ -5,7 +5,13 @@ 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. +// Used when View must ignore the storage object's opaque key / missing +// Content-Disposition filename (see FileControl x-file.fileName). +export function downloadBlob(blob: Blob, fileName: string): void { const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url From a6fd067278befeaa11813151a56cc043955e1794 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 9 Oct 2026 12:56:55 -0700 Subject: [PATCH 2/4] feat(jsonforms-renderers): View for viewable files, Download named by x-file.fileName Split View from Download per #99 so naming no longer forces a save for images, PDFs, and HTML. Extract file helpers and downloadFromUrl, surface per-row errors, and document CORS plus base-name indexing. Co-authored-by: Cursor --- packages/jsonforms-renderers/README.md | 2 +- packages/jsonforms-renderers/dev/fixtures.ts | 11 + .../src/renderers/FileControl.test.tsx | 198 +++++++++++++- .../src/renderers/FileControl.tsx | 256 +++++++++++------- .../src/utils/download.test.ts | 70 ++++- .../jsonforms-renderers/src/utils/download.ts | 31 ++- .../src/utils/file.test.ts | 59 ++++ .../jsonforms-renderers/src/utils/file.ts | 39 +++ 8 files changed, 558 insertions(+), 108 deletions(-) create mode 100644 packages/jsonforms-renderers/src/utils/file.test.ts create mode 100644 packages/jsonforms-renderers/src/utils/file.ts diff --git a/packages/jsonforms-renderers/README.md b/packages/jsonforms-renderers/README.md index 39f3960..360416e 100644 --- a/packages/jsonforms-renderers/README.md +++ b/packages/jsonforms-renderers/README.md @@ -21,7 +21,7 @@ This package provides a collection of JSON Forms controls and layout renderers t - **[BooleanControl](src/renderers/BooleanControl.tsx)**: Toggle switch control for boolean values. - **[RadioControl](src/renderers/RadioControl.tsx)**: Option picker rendered as radio group buttons. - **[SelectControl](src/renderers/SelectControl.tsx)**: Select drop-down control for `enum` or `oneOf` schemas. -- **[FileControl](src/renderers/FileControl.tsx)**: File picker. Optional `x-file.fileName` (e.g. `DC BAGS DETAILS _{index}.xlsx`) sets the row label and View download name. +- **[FileControl](src/renderers/FileControl.tsx)**: File picker. View opens browser-viewable types in a tab. Download (every row) saves under optional `x-file.fileName` as a base name (`Attachment_{index}`); `{index}` is the file's 1-based position and shifts when an earlier file is removed. The real extension is appended. Download fetches the signed storage URL in the browser, so the bucket must allow CORS GET from the app origin. ### Layouts diff --git a/packages/jsonforms-renderers/dev/fixtures.ts b/packages/jsonforms-renderers/dev/fixtures.ts index 5ab9640..27930fd 100644 --- a/packages/jsonforms-renderers/dev/fixtures.ts +++ b/packages/jsonforms-renderers/dev/fixtures.ts @@ -446,6 +446,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: { @@ -453,6 +463,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/src/renderers/FileControl.test.tsx b/packages/jsonforms-renderers/src/renderers/FileControl.test.tsx index 3593653..317eedc 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,85 @@ 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)} + config={{ restrict: !enabled }} + 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 +130,10 @@ function renderForm(seed: Data) { render( { if (live) writes.push(data) }} @@ -78,21 +142,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 +168,115 @@ 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') + }) +}) diff --git a/packages/jsonforms-renderers/src/renderers/FileControl.tsx b/packages/jsonforms-renderers/src/renderers/FileControl.tsx index d559b9f..40bfa67 100644 --- a/packages/jsonforms-renderers/src/renderers/FileControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/FileControl.tsx @@ -1,18 +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 { downloadBlob, renderFileName } from '../utils/download' +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 } @@ -20,7 +29,13 @@ interface XFileOptions { maxFiles?: number maxSize?: number accept?: string - /** View/label name template; `{index}` is 1-based. e.g. `DC BAGS DETAILS _{index}.xlsx`. */ + /** + * 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 } @@ -76,14 +91,9 @@ const FileControl = ({ const isMulti = maxFiles > 1 const isEnabled = enabled !== false - const displayNameFor = (key: string, index: number): string => { - const fallback = fileEntries[key]?.name ?? 'Uploaded File' - if (!fileNameTemplate) return fallback - return renderFileName(fileNameTemplate, { index }, fallback) - } - 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) @@ -91,6 +101,26 @@ const FileControl = ({ const currentKeys = normalizeData(data) const atLimit = currentKeys.length >= maxFiles + const rowLabel = (key: string, index: number): string => { + const entry = fileEntries[key] + if (fileNameTemplate) { + return downloadName(fileNameTemplate, { index }, { name: entry?.name, key }) + } + return entry?.name ?? 'Uploaded File' + } + + 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 + } + useEffect(() => { return () => { activeBlobs.current.forEach((url) => URL.revokeObjectURL(url)) @@ -111,14 +141,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 } @@ -132,7 +155,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 })) @@ -162,12 +190,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 = '' } } @@ -184,6 +212,12 @@ const FileControl = ({ delete next[key] return next }) + setRowErrors((prev) => { + if (!prev[key]) return prev + const next = { ...prev } + delete next[key] + return next + }) 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 @@ -193,51 +227,52 @@ const FileControl = ({ handleChange(path, isMulti ? (newKeys.length > 0 ? newKeys : undefined) : newKeys[0]) } + const clearRowError = (key: string) => { + setRowErrors((prev) => { + if (!prev[key]) return prev + const next = { ...prev } + delete next[key] + return next + }) + } + const onView = async (e: React.MouseEvent, key: string) => { e.preventDefault() - const index = currentKeys.indexOf(key) + 1 - const viewName = displayNameFor(key, index) - - // When a schema fileName is set, fetch as a Blob and download under that - // name so View works the same for trader and officer (storage URLs have no - // meaningful Content-Disposition filename). Without fileName, keep the - // previous open-in-tab behaviour. - if (fileNameTemplate) { - try { - let sourceUrl = fileEntries[key]?.blobUrl - if (!sourceUrl) { - const result = await uploadContext?.getDownloadUrl?.(key) - sourceUrl = result?.url - } - if (!sourceUrl) { - setError('Unable to open file.') - return - } - const response = await fetch(sourceUrl) - if (!response.ok) { - setError('Unable to open file.') - return - } - downloadBlob(await response.blob(), viewName) - } catch { - setError('Unable to open file.') - } - return - } + 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() + setRowErrors((prev) => ({ ...prev, [key]: 'Unable to open file.' })) + } } catch { newWindow.close() + setRowErrors((prev) => ({ ...prev, [key]: 'Unable to open file.' })) + } + } + + const onDownload = async (key: string, index: number) => { + clearRowError(key) + const name = downloadName(fileNameTemplate, { index }, { name: fileEntries[key]?.name, key }) + try { + const url = await resolveFileUrl(key) + if (!url) { + setRowErrors((prev) => ({ ...prev, [key]: 'Unable to download file.' })) + return + } + await downloadFromUrl(url, name) + } catch { + setRowErrors((prev) => ({ ...prev, [key]: 'Unable to download file.' })) } } @@ -262,48 +297,87 @@ const FileControl = ({ {/* ── Uploaded file rows ── */} - {currentKeys.map((key, position) => ( - - - - - - - { + const index = position + 1 + const originalName = fileEntries[key]?.name + const showSecondary = Boolean(fileNameTemplate && originalName) + const rowError = rowErrors[key] + const canView = isBrowserViewable(fileForViewability(key)) + + return ( + + + - {displayNameFor(key, position + 1)} - - - - - - {isEnabled && ( - handleRemove(key)}> - - - )} + + + + + {rowLabel(key, index)} + + {showSecondary && ( + + {originalName} + + )} + + + {canView && ( + + )} + + void onDownload(key, index)} + > + + + + + {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 022a645..fc3dd3e 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 { downloadBlob, downloadTextFile, renderFileName, sanitizeFileName } from './download' +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 @@ -65,15 +72,72 @@ describe('downloadBlob', () => { 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('DC BAGS DETAILS _1.xlsx') + expect(this.download).toBe('Attachment_1.xlsx') }) - downloadBlob(new Blob(['sheet'], { type: 'application/vnd.ms-excel' }), 'DC BAGS DETAILS _1.xlsx') + downloadBlob(new Blob(['sheet'], { type: 'application/vnd.ms-excel' }), 'Attachment_1.xlsx') expect(click).toHaveBeenCalledTimes(1) }) }) +describe('downloadFromUrl', () => { + 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 525386e..57e3a45 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, @@ -9,8 +10,6 @@ export function downloadTextFile(content: string, fileName: string, mimeType: st } // Trigger a browser download from an already-fetched Blob under a chosen name. -// Used when View must ignore the storage object's opaque key / missing -// Content-Disposition filename (see FileControl x-file.fileName). export function downloadBlob(blob: Blob, fileName: string): void { const url = URL.createObjectURL(blob) const anchor = document.createElement('a') @@ -20,6 +19,34 @@ export function downloadBlob(blob: Blob, fileName: string): void { URL.revokeObjectURL(url) } +// 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: // what browsers themselves replace in a download's name. const RESERVED = new Set(['/', '\\', '<', '>', ':', '"', '|', '?', '*']) 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) +} From 60df57c7f9759a1eda05e5cc21f7a915f576496b Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 11 Oct 2026 16:55:46 -0700 Subject: [PATCH 3/4] style(jsonforms-renderers): prettier-format file-control docs Co-authored-by: Cursor --- packages/jsonforms-renderers/docs/file-control.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/jsonforms-renderers/docs/file-control.md b/packages/jsonforms-renderers/docs/file-control.md index aa1694e..ff379fb 100644 --- a/packages/jsonforms-renderers/docs/file-control.md +++ b/packages/jsonforms-renderers/docs/file-control.md @@ -35,12 +35,12 @@ The bound schema node must use `format: "file"`: ## 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). | +| 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). | The same keys may also be set under `uischema.options` for older forms; schema `x-file` wins when both are present. From 9e08f3a163c28134bc127185dacce869179a51b6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 11 Oct 2026 20:04:15 -0700 Subject: [PATCH 4/4] docs(jsonforms-renderers): clarify fileName options and CORS wording Also use fake timers for deferred downloadBlob revoke in tests. Co-authored-by: Cursor --- .../jsonforms-renderers/docs/file-control.md | 4 +-- .../src/utils/download.test.ts | 29 ++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/jsonforms-renderers/docs/file-control.md b/packages/jsonforms-renderers/docs/file-control.md index ff379fb..3ccf12d 100644 --- a/packages/jsonforms-renderers/docs/file-control.md +++ b/packages/jsonforms-renderers/docs/file-control.md @@ -42,7 +42,7 @@ The bound schema node must use `format: "file"`: | `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). | -The same keys may also be set under `uischema.options` for older forms; schema `x-file` wins when both are present. +`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 @@ -70,6 +70,6 @@ For a just-uploaded file, both actions use the local blob URL. For a saved form ## 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 deployment (trader app, agency portal, etc.); it is outside this package. +- **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/utils/download.test.ts b/packages/jsonforms-renderers/src/utils/download.test.ts index 79f2d18..b4d2ab9 100644 --- a/packages/jsonforms-renderers/src/utils/download.test.ts +++ b/packages/jsonforms-renderers/src/utils/download.test.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { downloadBlob, downloadFromUrl, @@ -15,19 +15,25 @@ import { // 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(async () => { - // downloadBlob revokes on the next turn; wait so cleanup does not delete - // revokeObjectURL before that timeout runs. - await new Promise((r) => setTimeout(r, 0)) +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 @@ -57,7 +63,7 @@ describe('downloadTextFile', () => { expect(click).toHaveBeenCalledTimes(1) }) - it('revokes the object URL after triggering the download', async () => { + it('revokes the object URL after triggering the download', () => { URL.createObjectURL = vi.fn().mockReturnValue('blob:mock-url') const revokeObjectURL = vi.fn() URL.revokeObjectURL = revokeObjectURL @@ -66,11 +72,16 @@ describe('downloadTextFile', () => { downloadTextFile('1', 'a.xml', 'application/xml') expect(revokeObjectURL).not.toHaveBeenCalled() - await vi.waitFor(() => expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')) + 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() @@ -86,6 +97,10 @@ describe('downloadBlob', () => { }) describe('downloadFromUrl', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { vi.unstubAllGlobals() })