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
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,17 @@ interface FileUploadProps {
isPreview?: boolean
previewValue?: any | null
disabled?: boolean
/**
* Controlled value. When `onValueChange` is provided the component reads from
* this prop and writes through `onValueChange` instead of the subblock store,
* letting it be embedded where the value lives outside a subblock (e.g. a
* single field inside the input-format editor).
*/
value?: UploadedFile | UploadedFile[] | null
onValueChange?: (value: UploadedFile | UploadedFile[] | null) => void
}

interface UploadedFile {
export interface UploadedFile {
name: string
path: string
key?: string
Expand Down Expand Up @@ -165,9 +173,25 @@ export function FileUpload({
isPreview = false,
previewValue,
disabled = false,
value: controlledValue,
onValueChange,
}: FileUploadProps) {
const activeSearchTarget = useActiveSearchTarget()
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
const isControlled = onValueChange !== undefined

/**
* Persists a new value. In controlled mode the caller owns persistence; in
* store mode we write through the subblock store and notify collaborators.
*/
const commitValue = (next: UploadedFile | UploadedFile[] | null) => {
if (isControlled) {
onValueChange(next)
return
}
setStoreValue(next)
useWorkflowStore.getState().triggerUpdate()
}
const [modelValue] = useSubBlockValue(blockId, 'model')
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
const [uploadProgress, setUploadProgress] = useState(0)
Expand All @@ -191,7 +215,7 @@ export function FileUpload({
const uploadFileMutation = useUploadWorkspaceFile()
const queryClient = useQueryClient()

const value = isPreview ? previewValue : storeValue
const value = isControlled ? controlledValue : isPreview ? previewValue : storeValue

const maxSizeInBytes = useMemo(() => {
const fallback = maxSize * 1024 * 1024
Expand Down Expand Up @@ -413,11 +437,9 @@ export function FileUpload({

const newFiles = Array.from(uniqueFiles.values())

setStoreValue(newFiles)
useWorkflowStore.getState().triggerUpdate()
commitValue(newFiles)
} else {
setStoreValue(uploadedFiles[0] || null)
useWorkflowStore.getState().triggerUpdate()
commitValue(uploadedFiles[0] || null)
}
} catch (error) {
logger.error(getErrorMessage(error, 'Failed to upload file(s)'), activeWorkflowId)
Expand Down Expand Up @@ -459,12 +481,11 @@ export function FileUpload({
uniqueFiles.set(uploadedFile.path, uploadedFile)
const newFiles = Array.from(uniqueFiles.values())

setStoreValue(newFiles)
commitValue(newFiles)
} else {
setStoreValue(uploadedFile)
commitValue(uploadedFile)
}

useWorkflowStore.getState().triggerUpdate()
logger.info(`Selected workspace file: ${selectedFile.name}`, activeWorkflowId)
}

Expand Down Expand Up @@ -501,12 +522,10 @@ export function FileUpload({
if (multiple) {
const filesArray = Array.isArray(value) ? value : value ? [value] : []
const updatedFiles = filesArray.filter((f) => f.path !== file.path)
setStoreValue(updatedFiles.length > 0 ? updatedFiles : null)
commitValue(updatedFiles.length > 0 ? updatedFiles : null)
} else {
setStoreValue(null)
commitValue(null)
}

useWorkflowStore.getState().triggerUpdate()
} catch (error) {
logger.error(getErrorMessage(error, 'Failed to remove file'), activeWorkflowId)
} finally {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest'
import type { InputFormatFile } from '@/lib/workflows/input-format'
import {
controlValueToFiles,
defaultFileFieldMode,
filesToControlValue,
serializeInputFormatFiles,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format-files'

const file: InputFormatFile = {
id: 'f1',
name: 'doc.pdf',
url: '/api/files/serve/workspace%2Fws-1%2F1700000000000-doc.pdf?context=workspace',
key: 'key',
size: 10,
type: 'application/pdf',
}

describe('filesToControlValue', () => {
it.concurrent('maps url -> path for the FileUpload value shape', () => {
expect(filesToControlValue([file])).toEqual([
{
name: file.name,
path: file.url,
key: file.key,
size: file.size,
type: file.type,
},
])
})

it.concurrent('round-trips through controlValueToFiles without data loss', () => {
expect(controlValueToFiles(filesToControlValue([file]), [file])).toEqual([file])
})
})

describe('controlValueToFiles', () => {
it.concurrent('preserves the stable id of an existing file (matched by key)', () => {
const control = [
{ name: 'doc.pdf', path: '/moved', key: 'key', size: 10, type: 'application/pdf' },
]
expect(controlValueToFiles(control, [file])[0].id).toBe('f1')
})

it.concurrent('matches an existing file by url when key is absent', () => {
const control = [{ name: 'doc.pdf', path: file.url, size: 10, type: 'application/pdf' }]
expect(controlValueToFiles(control, [file])[0].id).toBe('f1')
})

it.concurrent('generates an id for a newly added file', () => {
const control = [
{
name: 'new.pdf',
path: '/api/files/serve/new',
key: 'new',
size: 5,
type: 'application/pdf',
},
]
const result = controlValueToFiles(control, [file])
expect(result[0].id).toEqual(expect.any(String))
expect(result[0].id).not.toBe('f1')
expect(result[0].url).toBe('/api/files/serve/new')
})

it.concurrent('normalizes a single object or null to an array', () => {
const single = {
name: file.name,
path: file.url,
key: file.key,
size: file.size,
type: file.type,
}
expect(controlValueToFiles(single, [file])).toEqual([file])
expect(controlValueToFiles(null, [file])).toEqual([])
})
})

describe('serializeInputFormatFiles', () => {
it.concurrent('serializes to JSON that parses back to the same files', () => {
expect(JSON.parse(serializeInputFormatFiles([file]))).toEqual([file])
})

it.concurrent('returns an empty string for no files', () => {
expect(serializeInputFormatFiles([])).toBe('')
})
})

describe('defaultFileFieldMode', () => {
it.concurrent('defaults to upload for empty or whitespace values', () => {
expect(defaultFileFieldMode(undefined)).toBe('upload')
expect(defaultFileFieldMode('')).toBe('upload')
expect(defaultFileFieldMode(' ')).toBe('upload')
})

it.concurrent('uses upload for an empty array or run-ready files', () => {
expect(defaultFileFieldMode('[]')).toBe('upload')
expect(defaultFileFieldMode(JSON.stringify([file]))).toBe('upload')
})

it.concurrent('falls back to json for legacy free-form values (no data loss)', () => {
expect(defaultFileFieldMode('C:/Users/x/budget.xlsx')).toBe('json')
expect(defaultFileFieldMode('[{"data":"<base64>","name":"x.pdf"}]')).toBe('json')
expect(defaultFileFieldMode('{"csv":"a,b,c"}')).toBe('json')
})

it.concurrent('uses json when only some entries are run-ready (no silent drop)', () => {
expect(defaultFileFieldMode(JSON.stringify([file, { name: 'legacy-only' }]))).toBe('json')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { generateId } from '@sim/utils/id'
import { type InputFormatFile, parseInputFormatFiles } from '@/lib/workflows/input-format'
import type { UploadedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload'

/**
* Pure adapters bridging a file-typed input-format field's stored value (a JSON
* string of run-ready {@link InputFormatFile} objects) and the {@link FileUpload}
* component's value shape (which keys off `path`). Kept separate from the React
* component so they can be unit-tested without a DOM.
*/

/**
* Maps stored run-ready file objects to the {@link FileUpload} value shape.
*/
export function filesToControlValue(files: InputFormatFile[]): UploadedFile[] {
return files.map((file) => ({
name: file.name,
path: file.url,
key: file.key,
size: file.size,
type: file.type,
}))
}

/**
* Maps a {@link FileUpload} value back to stored run-ready file objects,
* preserving the stable `id` of files that were already present.
*/
export function controlValueToFiles(
value: UploadedFile | UploadedFile[] | null,
previous: InputFormatFile[]
): InputFormatFile[] {
const uploaded = Array.isArray(value) ? value : value ? [value] : []
return uploaded.map((file) => {
const existing = previous.find(
(prev) => (file.key && prev.key === file.key) || prev.url === file.path
)
return {
id: existing?.id ?? generateId(),
name: file.name,
url: file.path,
key: file.key,
size: file.size,
type: file.type,
}
})
}

/**
* Serializes run-ready file objects into a field value string (empty when none).
*/
export function serializeInputFormatFiles(files: InputFormatFile[]): string {
return files.length > 0 ? JSON.stringify(files, null, 2) : ''
}

/**
* Default editor mode for a file field: the uploader, unless the stored value is
* legacy free-form content (raw text or a non-file array) that only the JSON
* editor can represent without data loss.
*/
export function defaultFileFieldMode(value: string | undefined): 'upload' | 'json' {
if (!value || !value.trim()) return 'upload'
let parsed: unknown
try {
parsed = JSON.parse(value)
} catch {
return 'json'
}
if (!Array.isArray(parsed)) return 'json'
if (parsed.length === 0) return 'upload'
// Only use the uploader when EVERY entry is run-ready; if any entry is legacy
// or partial, stay in JSON mode so the uploader can't drop the entries it
// cannot represent when the field is next saved.
return parseInputFormatFiles(parsed).length === parsed.length ? 'upload' : 'json'
}
Loading
Loading