diff --git a/apps/sim/blocks/blocks/file-folders.test.ts b/apps/sim/blocks/blocks/file-folders.test.ts index 09fd1ddf946..842d8fe991f 100644 --- a/apps/sim/blocks/blocks/file-folders.test.ts +++ b/apps/sim/blocks/blocks/file-folders.test.ts @@ -145,7 +145,7 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { describe('move file', () => { it('sends the canonical destination the picker produced', () => { const params = paramsFor('file_move', { - moveFileId: 'wf_123', + moveFileInput: 'wf_123', moveTargetRef: '/Reports/Q3%20Results', }) @@ -157,13 +157,66 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { }) it('omits the destination when no folder is picked', () => { - const params = paramsFor('file_move', { moveFileId: 'wf_123' }) + const params = paramsFor('file_move', { moveFileInput: 'wf_123' }) expect(params.folderPath).toBeUndefined() expect(fileManageMoveBodySchema.safeParse({ operation: 'move', ...params }).success).toBe( true ) }) + + /* + * The file is a basic/advanced pair like every other single-file operand, + * so it can be picked as well as typed. The tool takes only an id, so a + * picked file travels as the id its selection carries. + */ + it('pairs a workspace file picker with the typed id', () => { + const picker = FileV5Block.subBlocks.find((subBlock) => subBlock.id === 'moveFile') + const typed = FileV5Block.subBlocks.find((subBlock) => subBlock.id === 'moveFileId') + + expect(picker?.type).toBe('file-upload') + expect(picker?.mode).toBe('basic') + expect(picker?.canonicalParamId).toBe('moveFileInput') + expect(typed?.type).toBe('short-input') + expect(typed?.mode).toBe('advanced') + expect(typed?.canonicalParamId).toBe('moveFileInput') + expect(typed?.condition).toEqual(picker?.condition) + expect(typed?.required).toEqual(picker?.required) + }) + + it('moves a picked file by the id it carries', () => { + const params = paramsFor('file_move', { + moveFileInput: { id: 'wf_abc', name: 'notes.md', key: 'workspace/ws-1/notes.md' }, + }) + + expect(params.fileId).toBe('wf_abc') + expect(fileManageMoveBodySchema.safeParse({ operation: 'move', ...params }).success).toBe( + true + ) + }) + + it('reads one id from the serialized list a reference can produce', () => { + expect(paramsFor('file_move', { moveFileInput: '["wf_abc"]' }).fileId).toBe('wf_abc') + }) + + it('refuses more than one file', () => { + expect(() => paramsFor('file_move', { moveFileInput: '["wf_a","wf_b"]' })).toThrow( + /single file/ + ) + expect(() => + paramsFor('file_move', { moveFileInput: [{ id: 'wf_a' }, { id: 'wf_b' }] }) + ).toThrow(/single file/) + }) + + it('refuses a file object that carries no id, naming the remedy', () => { + expect(() => + paramsFor('file_move', { moveFileInput: { name: 'notes.md', key: 'k' } }) + ).toThrow(/workspace file ID/) + }) + + it('refuses an empty operand', () => { + expect(() => paramsFor('file_move', {})).toThrow(/File is required for move/) + }) }) describe('write takes a folder, append does not', () => { diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index cff055af97e..23584ee32d4 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -92,6 +92,7 @@ const APPEND_FILE_FIELD = ['appendFile', 'appendFileName'] as const const COMPRESS_FILE_FIELD = ['compressFile', 'compressFileId'] as const const DECOMPRESS_FILE_FIELD = ['decompressFile', 'decompressFileId'] as const const SHARE_FILE_FIELD = ['shareFile', 'shareFileId'] as const +const MOVE_FILE_FIELD = ['moveFile', 'moveFileId'] as const /* Text and file are mutually exclusive sources, so the clause names whichever one the card actually carries. */ const WRITE_CONTENT_FIELD = ['content', 'writeFile', 'writeFileId'] as const @@ -1129,6 +1130,7 @@ export const FileV5Block: BlockConfig = { - Use Write to create a new workspace file and Append to add content to an existing one. Write adds a numeric suffix when the name is taken; turn on "Overwrite Existing File" to replace the contents of the file at that exact path (folder and name) instead — a same-named file in another folder is left alone. - Use Compress to bundle one or more files into a single .zip archive stored in the workspace. The new archive is returned in the "files" output. - Use Decompress to extract a .zip archive back into the workspace; the extracted files are returned in the "files" output, ready to chain into Get Content or downstream blocks. + - Move File takes one workspace file, picked or given as a canonical file ID such as an earlier block's file output, and the folder to move it into. `, canvasPresentation: { defaultTitle: 'File', @@ -1178,7 +1180,7 @@ export const FileV5Block: BlockConfig = { file_delete_folder: [{ text: 'Delete folder', field: FOLDER_PATH_FIELD, core: true }], file_restore_folder: [{ text: 'Restore folder', field: 'restoreFolderId', core: true }], file_move: [ - { text: 'Move', field: 'moveFileId', core: true }, + { text: 'Move', field: MOVE_FILE_FIELD, core: true }, { text: 'into', field: MOVE_TARGET_FIELD }, ], file_decompress: [{ text: 'Unzip', field: DECOMPRESS_FILE_FIELD, core: true }], @@ -1873,11 +1875,24 @@ export const FileV5Block: BlockConfig = { type: 'switch' as SubBlockType, condition: { field: 'operation', value: 'file_delete_folder' }, }, + { + id: 'moveFile', + title: 'File', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'moveFileInput', + acceptedTypes: '*', + placeholder: 'Select a workspace file', + mode: 'basic', + condition: { field: 'operation', value: 'file_move' }, + required: { field: 'operation', value: 'file_move' }, + }, { id: 'moveFileId', title: 'File ID', type: 'short-input' as SubBlockType, - placeholder: 'Canonical workspace file ID', + canonicalParamId: 'moveFileInput', + placeholder: 'Workspace file ID', + mode: 'advanced', condition: { field: 'operation', value: 'file_move' }, required: { field: 'operation', value: 'file_move' }, }, @@ -2045,8 +2060,35 @@ export const FileV5Block: BlockConfig = { } if (operation === 'file_move') { + const moveInput = params.moveFileInput + if (!moveInput) { + throw new Error('File is required for move') + } + + /* + * The tool takes an id and nothing else, so a picked file resolves + * here by the id every picker selection and in-place upload carries. + * A file object without one is refused with the remedy rather than + * forwarded as a shape the contract rejects. + */ + const fileIds = parseReadFileIds(moveInput) + if (Array.isArray(fileIds)) { + throw new Error('Move File accepts a single file at a time') + } + const file = fileIds + ? null + : (normalizeFileInput(moveInput, { + single: true, + errorMessage: 'Move File accepts a single file at a time', + }) as Record | undefined) + const pickedId = typeof file?.id === 'string' ? file.id.trim() : '' + const fileId = fileIds ?? pickedId + if (!fileId) { + throw new Error('Could not determine the file to move; pass its workspace file ID') + } + return { - fileId: optionalText(params.moveFileId), + fileId, folderPath: optionalText(params.moveTargetRef), workspaceId: params._context?.workspaceId, } @@ -2293,9 +2335,9 @@ export const FileV5Block: BlockConfig = { type: 'string', description: 'Folder the file is moved into (move file)', }, - moveFileId: { - type: 'string', - description: 'Canonical ID of the file to move (move file)', + moveFileInput: { + type: 'json', + description: 'Selected workspace file or canonical file ID to move (move file)', }, restoreFolderId: { type: 'string',