Skip to content

Commit f7ca85a

Browse files
committed
refactor(input-format): extract file adapters, share run-input builder, harden types
Follow-up cleanup from /simplify + /cleanup review: - Extract pure file adapters into input-format-files.ts (filesToControlValue, controlValueToFiles, serializeInputFormatFiles, defaultFileFieldMode) so they are unit-tested without a DOM; add tests. - Derive InputFormatFile from the canonical executor UserFile so the editor and runtime file shapes can't drift. - Consolidate the two manual run-input builders into one buildInputFormatInput helper so manual run and run-from-block handle files identically. - Narrow file-field detection to the canonical file[] (matches the field-type dropdown and the existing execution/webhook file paths) so no pre-existing non-file[] field changes behavior. - defaultFileFieldMode parses once; the file value is only parsed in upload mode. - Add lib/component unit tests (45 passing).
1 parent 50bab80 commit f7ca85a

6 files changed

Lines changed: 351 additions & 210 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { describe, expect, it } from 'vitest'
2+
import type { InputFormatFile } from '@/lib/workflows/input-format'
3+
import {
4+
controlValueToFiles,
5+
defaultFileFieldMode,
6+
filesToControlValue,
7+
serializeInputFormatFiles,
8+
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format-files'
9+
10+
const file: InputFormatFile = {
11+
id: 'f1',
12+
name: 'doc.pdf',
13+
url: '/api/files/serve/key',
14+
key: 'key',
15+
size: 10,
16+
type: 'application/pdf',
17+
}
18+
19+
describe('filesToControlValue', () => {
20+
it.concurrent('maps url -> path for the FileUpload value shape', () => {
21+
expect(filesToControlValue([file])).toEqual([
22+
{
23+
name: 'doc.pdf',
24+
path: '/api/files/serve/key',
25+
key: 'key',
26+
size: 10,
27+
type: 'application/pdf',
28+
},
29+
])
30+
})
31+
32+
it.concurrent('round-trips through controlValueToFiles without data loss', () => {
33+
expect(controlValueToFiles(filesToControlValue([file]), [file])).toEqual([file])
34+
})
35+
})
36+
37+
describe('controlValueToFiles', () => {
38+
it.concurrent('preserves the stable id of an existing file (matched by key)', () => {
39+
const control = [
40+
{ name: 'doc.pdf', path: '/moved', key: 'key', size: 10, type: 'application/pdf' },
41+
]
42+
expect(controlValueToFiles(control, [file])[0].id).toBe('f1')
43+
})
44+
45+
it.concurrent('matches an existing file by url when key is absent', () => {
46+
const control = [
47+
{ name: 'doc.pdf', path: '/api/files/serve/key', size: 10, type: 'application/pdf' },
48+
]
49+
expect(controlValueToFiles(control, [file])[0].id).toBe('f1')
50+
})
51+
52+
it.concurrent('generates an id for a newly added file', () => {
53+
const control = [
54+
{
55+
name: 'new.pdf',
56+
path: '/api/files/serve/new',
57+
key: 'new',
58+
size: 5,
59+
type: 'application/pdf',
60+
},
61+
]
62+
const result = controlValueToFiles(control, [file])
63+
expect(result[0].id).toEqual(expect.any(String))
64+
expect(result[0].id).not.toBe('f1')
65+
expect(result[0].url).toBe('/api/files/serve/new')
66+
})
67+
68+
it.concurrent('normalizes a single object or null to an array', () => {
69+
const single = {
70+
name: 'doc.pdf',
71+
path: '/api/files/serve/key',
72+
key: 'key',
73+
size: 10,
74+
type: 'application/pdf',
75+
}
76+
expect(controlValueToFiles(single, [file])).toEqual([file])
77+
expect(controlValueToFiles(null, [file])).toEqual([])
78+
})
79+
})
80+
81+
describe('serializeInputFormatFiles', () => {
82+
it.concurrent('serializes to JSON that parses back to the same files', () => {
83+
expect(JSON.parse(serializeInputFormatFiles([file]))).toEqual([file])
84+
})
85+
86+
it.concurrent('returns an empty string for no files', () => {
87+
expect(serializeInputFormatFiles([])).toBe('')
88+
})
89+
})
90+
91+
describe('defaultFileFieldMode', () => {
92+
it.concurrent('defaults to upload for empty or whitespace values', () => {
93+
expect(defaultFileFieldMode(undefined)).toBe('upload')
94+
expect(defaultFileFieldMode('')).toBe('upload')
95+
expect(defaultFileFieldMode(' ')).toBe('upload')
96+
})
97+
98+
it.concurrent('uses upload for an empty array or run-ready files', () => {
99+
expect(defaultFileFieldMode('[]')).toBe('upload')
100+
expect(defaultFileFieldMode(JSON.stringify([file]))).toBe('upload')
101+
})
102+
103+
it.concurrent('falls back to json for legacy free-form values (no data loss)', () => {
104+
expect(defaultFileFieldMode('C:/Users/x/budget.xlsx')).toBe('json')
105+
expect(defaultFileFieldMode('[{"data":"<base64>","name":"x.pdf"}]')).toBe('json')
106+
expect(defaultFileFieldMode('{"csv":"a,b,c"}')).toBe('json')
107+
})
108+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { generateId } from '@sim/utils/id'
2+
import { type InputFormatFile, parseInputFormatFiles } from '@/lib/workflows/input-format'
3+
import type { UploadedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload'
4+
5+
/**
6+
* Pure adapters bridging a file-typed input-format field's stored value (a JSON
7+
* string of run-ready {@link InputFormatFile} objects) and the {@link FileUpload}
8+
* component's value shape (which keys off `path`). Kept separate from the React
9+
* component so they can be unit-tested without a DOM.
10+
*/
11+
12+
/**
13+
* Maps stored run-ready file objects to the {@link FileUpload} value shape.
14+
*/
15+
export function filesToControlValue(files: InputFormatFile[]): UploadedFile[] {
16+
return files.map((file) => ({
17+
name: file.name,
18+
path: file.url,
19+
key: file.key,
20+
size: file.size,
21+
type: file.type,
22+
}))
23+
}
24+
25+
/**
26+
* Maps a {@link FileUpload} value back to stored run-ready file objects,
27+
* preserving the stable `id` of files that were already present.
28+
*/
29+
export function controlValueToFiles(
30+
value: UploadedFile | UploadedFile[] | null,
31+
previous: InputFormatFile[]
32+
): InputFormatFile[] {
33+
const uploaded = Array.isArray(value) ? value : value ? [value] : []
34+
return uploaded.map((file) => {
35+
const existing = previous.find(
36+
(prev) => (file.key && prev.key === file.key) || prev.url === file.path
37+
)
38+
return {
39+
id: existing?.id ?? generateId(),
40+
name: file.name,
41+
url: file.path,
42+
key: file.key,
43+
size: file.size,
44+
type: file.type,
45+
}
46+
})
47+
}
48+
49+
/**
50+
* Serializes run-ready file objects into a field value string (empty when none).
51+
*/
52+
export function serializeInputFormatFiles(files: InputFormatFile[]): string {
53+
return files.length > 0 ? JSON.stringify(files, null, 2) : ''
54+
}
55+
56+
/**
57+
* Default editor mode for a file field: the uploader, unless the stored value is
58+
* legacy free-form content (raw text or a non-file array) that only the JSON
59+
* editor can represent without data loss.
60+
*/
61+
export function defaultFileFieldMode(value: string | undefined): 'upload' | 'json' {
62+
if (!value || !value.trim()) return 'upload'
63+
let parsed: unknown
64+
try {
65+
parsed = JSON.parse(value)
66+
} catch {
67+
return 'json'
68+
}
69+
if (!Array.isArray(parsed)) return 'json'
70+
return parsed.length === 0 || parseInputFormatFiles(parsed).length > 0 ? 'upload' : 'json'
71+
}

0 commit comments

Comments
 (0)