Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/jsonforms-renderers/dev/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,13 +452,24 @@ 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: {
type: 'VerticalLayout',
elements: [
{ type: 'Control', scope: '#/properties/avatar' },
{ type: 'Control', scope: '#/properties/attachments' },
{ type: 'Control', scope: '#/properties/namedAttachments' },
],
} as UISchemaElement,
},
Expand Down
75 changes: 75 additions & 0 deletions packages/jsonforms-renderers/docs/file-control.md
Original file line number Diff line number Diff line change
@@ -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.
227 changes: 216 additions & 11 deletions packages/jsonforms-renderers/src/renderers/FileControl.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -27,6 +27,8 @@ afterEach(() => {
for (const stop of finishers) stop()
finishers.length = 0
cleanup()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})

const schema: JsonSchema = {
Expand All @@ -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 (
<Theme>
<UploadProvider onUpload={(file) => Promise.resolve({ key: file.name })}>
<UploadProvider
onUpload={(file) => Promise.resolve({ key: file.name, name: file.name })}
getDownloadUrl={getDownloadUrl}
>
<JsonForms
schema={schema}
uischema={uischema}
schema={formSchema}
uischema={formUi}
data={initial}
renderers={radixRenderers}
onChange={({ data }) => onData(data as Data)}
readonly={!enabled}
/>
</UploadProvider>
</Theme>
)
}

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(() => {
Expand All @@ -70,6 +129,10 @@ function renderForm(seed: Data) {
render(
<Harness
seed={seed}
schema={options.schema}
uischema={options.uischema}
enabled={options.enabled}
getDownloadUrl={options.getDownloadUrl}
onData={(data) => {
if (live) writes.push(data)
}}
Expand All @@ -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])

Expand All @@ -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()
})
})
Loading
Loading