From 02f8a69c6abfa96c2b4d9828b4ebc5b13707f8a6 Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Wed, 26 Nov 2025 21:01:29 -0600 Subject: [PATCH] feat(platform): add Google Drive Picker field type for enhanced file selection (#11311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 🏗️ Changes This PR adds a Google Drive Picker field type to enhance the user experience of existing Google blocks, replacing manual file ID entry with a visual file picker. #### Backend Changes - **Added and types** in : - Configurable picker field with OAuth scope management - Support for multiselect, folder selection, and MIME type filtering - Proper access token handling for file downloads - **Enhanced Gmail blocks**: Updated attachment fields to use Google Drive Picker for better UX - **Enhanced Google Sheets blocks**: Updated spreadsheet selection to use picker instead of manual ID entry - **Added utility**: Async file download with virus scanning and 100MB size limit #### Frontend Changes - **Enhanced GoogleDrivePicker component**: Improved UI with folder icon and multiselect messaging - **Integrated picker in form renderers**: Auto-renders for fields with format - **Added shared GoogleDrivePickerInput component**: Eliminates code duplication between NodeInputs and RunAgentInputs - **Added type definitions**: Complete TypeScript support for picker schemas and responses #### Key Features - 🎯 **Visual file selection**: Replace manual Google Drive file ID entry with intuitive picker - 📁 **Flexible configuration**: Support for documents, spreadsheets, folders, and custom MIME types - 🔒 **Minimal OAuth scopes**: Uses scope for security (only access to user-selected files) - ⚡ **Enhanced UX**: Seamless integration in both block configuration and agent run modals - 🛡️ **Security**: Virus scanning and file size limits for downloaded attachments #### Migration Impact - **Backward compatible**: Existing blocks continue to work with manual ID entry - **Progressive enhancement**: New picker fields provide better UX for the same functionality - **No breaking changes**: all existing blocks should be unaffected This enhancement improves the user experience of Google blocks without introducing new systems or breaking existing functionality. ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x]Test multiple of the new blocks [of note is that the create spreadsheet block should be not used for now as it uses api not drive picker] - [x] chain the blocks together and pass values between them --------- Co-authored-by: Lluis Agusti Co-authored-by: Zamil Majdy Co-authored-by: Claude --- .../backend/backend/blocks/google/_drive.py | 198 ++++ .../backend/backend/blocks/google/sheets.py | 1032 ++++++++++++++--- .../legacy-builder/CustomNode/CustomNode.tsx | 116 +- .../components/legacy-builder/NodeInputs.tsx | 63 +- .../RunAgentInputs/RunAgentInputs.tsx | 30 +- .../GoogleDrivePicker/GoogleDrivePicker.tsx | 38 +- .../GoogleDrivePickerInput.tsx | 137 +++ .../GoogleDrivePicker/useGoogleDrivePicker.ts | 117 +- .../src/lib/autogpt-server-api/types.ts | 45 + 9 files changed, 1489 insertions(+), 287 deletions(-) create mode 100644 autogpt_platform/backend/backend/blocks/google/_drive.py create mode 100644 autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput.tsx diff --git a/autogpt_platform/backend/backend/blocks/google/_drive.py b/autogpt_platform/backend/backend/blocks/google/_drive.py new file mode 100644 index 0000000000..46fafd6857 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/google/_drive.py @@ -0,0 +1,198 @@ +import asyncio +import mimetypes +import uuid +from pathlib import Path +from typing import Any, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from backend.data.model import SchemaField +from backend.util.file import get_exec_file_path +from backend.util.request import Requests +from backend.util.type import MediaFileType +from backend.util.virus_scanner import scan_content_safe + +AttachmentView = Literal[ + "DOCS", + "DOCUMENTS", + "SPREADSHEETS", + "PRESENTATIONS", + "DOCS_IMAGES", + "FOLDERS", +] +ATTACHMENT_VIEWS: tuple[AttachmentView, ...] = ( + "DOCS", + "DOCUMENTS", + "SPREADSHEETS", + "PRESENTATIONS", + "DOCS_IMAGES", + "FOLDERS", +) + + +class GoogleDriveFile(BaseModel): + """Represents a single file/folder picked from Google Drive""" + + model_config = ConfigDict(populate_by_name=True) + + id: str = Field(description="Google Drive file/folder ID") + name: Optional[str] = Field(None, description="File/folder name") + mime_type: Optional[str] = Field( + None, + alias="mimeType", + description="MIME type (e.g., application/vnd.google-apps.document)", + ) + url: Optional[str] = Field(None, description="URL to open the file") + icon_url: Optional[str] = Field(None, alias="iconUrl", description="Icon URL") + is_folder: Optional[bool] = Field( + None, alias="isFolder", description="Whether this is a folder" + ) + + +def GoogleDrivePickerField( + multiselect: bool = False, + allow_folder_selection: bool = False, + allowed_views: Optional[list[AttachmentView]] = None, + allowed_mime_types: Optional[list[str]] = None, + scopes: Optional[list[str]] = None, + title: Optional[str] = None, + description: Optional[str] = None, + placeholder: Optional[str] = None, + **kwargs, +) -> Any: + """ + Creates a Google Drive Picker input field. + + Args: + multiselect: Allow selecting multiple files/folders (default: False) + allow_folder_selection: Allow selecting folders (default: False) + allowed_views: List of view types to show in picker (default: ["DOCS"]) + allowed_mime_types: Filter by MIME types (e.g., ["application/pdf"]) + title: Field title shown in UI + description: Field description/help text + placeholder: Placeholder text for the button + **kwargs: Additional SchemaField arguments (advanced, hidden, etc.) + + Returns: + Field definition that produces: + - Single GoogleDriveFile when multiselect=False + - list[GoogleDriveFile] when multiselect=True + + Example: + >>> class MyBlock(Block): + ... class Input(BlockSchema): + ... document: GoogleDriveFile = GoogleDrivePickerField( + ... title="Select Document", + ... allowed_views=["DOCUMENTS"], + ... ) + ... + ... files: list[GoogleDriveFile] = GoogleDrivePickerField( + ... title="Select Multiple Files", + ... multiselect=True, + ... allow_folder_selection=True, + ... ) + """ + # Build configuration that will be sent to frontend + picker_config = { + "multiselect": multiselect, + "allow_folder_selection": allow_folder_selection, + "allowed_views": list(allowed_views) if allowed_views else ["DOCS"], + } + + # Add optional configurations + if allowed_mime_types: + picker_config["allowed_mime_types"] = list(allowed_mime_types) + + # Determine required scopes based on config + base_scopes = scopes if scopes is not None else [] + picker_scopes: set[str] = set(base_scopes) + if allow_folder_selection: + picker_scopes.add("https://www.googleapis.com/auth/drive") + else: + # Use drive.file for minimal scope - only access files selected by user in picker + picker_scopes.add("https://www.googleapis.com/auth/drive.file") + + views = set(allowed_views or []) + if "SPREADSHEETS" in views: + picker_scopes.add("https://www.googleapis.com/auth/spreadsheets.readonly") + if "DOCUMENTS" in views or "DOCS" in views: + picker_scopes.add("https://www.googleapis.com/auth/documents.readonly") + + picker_config["scopes"] = sorted(picker_scopes) + + # Set appropriate default value + default_value = [] if multiselect else None + + # Use SchemaField to handle format properly + return SchemaField( + default=default_value, + title=title, + description=description, + placeholder=placeholder or "Choose from Google Drive", + format="google-drive-picker", + advanced=False, + json_schema_extra={ + "google_drive_picker_config": picker_config, + **kwargs, + }, + ) + + +DRIVE_API_URL = "https://www.googleapis.com/drive/v3/files" +_requests = Requests(trusted_origins=["https://www.googleapis.com"]) + + +def GoogleDriveAttachmentField( + *, + title: str, + description: str | None = None, + placeholder: str | None = None, + multiselect: bool = True, + allowed_mime_types: list[str] | None = None, + **extra: Any, +) -> Any: + return GoogleDrivePickerField( + multiselect=multiselect, + allowed_views=list(ATTACHMENT_VIEWS), + allowed_mime_types=allowed_mime_types, + title=title, + description=description, + placeholder=placeholder or "Choose files from Google Drive", + **extra, + ) + + +async def drive_file_to_media_file( + drive_file: GoogleDriveFile, *, graph_exec_id: str, access_token: str +) -> MediaFileType: + if drive_file.is_folder: + raise ValueError("Google Drive selection must be a file.") + if not access_token: + raise ValueError("Google Drive access token is required for file download.") + + url = f"{DRIVE_API_URL}/{drive_file.id}?alt=media" + response = await _requests.get( + url, headers={"Authorization": f"Bearer {access_token}"} + ) + + mime_type = drive_file.mime_type or response.headers.get( + "content-type", "application/octet-stream" + ) + + MAX_FILE_SIZE = 100 * 1024 * 1024 + if len(response.content) > MAX_FILE_SIZE: + raise ValueError( + f"File too large: {len(response.content)} bytes > {MAX_FILE_SIZE} bytes" + ) + + base_path = Path(get_exec_file_path(graph_exec_id, "")) + base_path.mkdir(parents=True, exist_ok=True) + + extension = mimetypes.guess_extension(mime_type, strict=False) or ".bin" + filename = f"{uuid.uuid4()}{extension}" + target_path = base_path / filename + + await scan_content_safe(response.content, filename=filename) + await asyncio.to_thread(target_path.write_bytes, response.content) + + return MediaFileType(str(target_path.relative_to(base_path))) diff --git a/autogpt_platform/backend/backend/blocks/google/sheets.py b/autogpt_platform/backend/backend/blocks/google/sheets.py index c10bcfb255..e8d2d1d74a 100644 --- a/autogpt_platform/backend/backend/blocks/google/sheets.py +++ b/autogpt_platform/backend/backend/blocks/google/sheets.py @@ -5,6 +5,7 @@ from typing import Any from google.oauth2.credentials import Credentials from googleapiclient.discovery import build +from backend.blocks.google._drive import GoogleDriveFile, GoogleDrivePickerField from backend.data.block import ( Block, BlockCategory, @@ -160,6 +161,7 @@ def _convert_dicts_to_rows( def _build_sheets_service(credentials: GoogleCredentials): + """Build Sheets service from platform credentials (with refresh token).""" settings = Settings() creds = Credentials( token=( @@ -180,6 +182,41 @@ def _build_sheets_service(credentials: GoogleCredentials): return build("sheets", "v4", credentials=creds) +def _validate_spreadsheet_file(spreadsheet_file: "GoogleDriveFile") -> str | None: + """Validate that the selected file is a Google Sheets spreadsheet. + + Returns None if valid, error message string if invalid. + """ + if spreadsheet_file.mime_type != "application/vnd.google-apps.spreadsheet": + file_type = spreadsheet_file.mime_type + file_name = spreadsheet_file.name + if file_type == "text/csv": + return f"Cannot use CSV file '{file_name}' with Google Sheets block. Please use a CSV reader block instead, or convert the CSV to a Google Sheets spreadsheet first." + elif file_type in [ + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ]: + return f"Cannot use Excel file '{file_name}' with Google Sheets block. Please use an Excel reader block instead, or convert to Google Sheets first." + else: + return f"Cannot use file '{file_name}' (type: {file_type}) with Google Sheets block. This block only works with Google Sheets spreadsheets." + return None + + +def _handle_sheets_api_error(error_msg: str, operation: str = "access") -> str: + """Convert common Google Sheets API errors to user-friendly messages.""" + if "Request contains an invalid argument" in error_msg: + return f"Invalid request to Google Sheets API. This usually means the file is not a Google Sheets spreadsheet, the range is invalid, or you don't have permission to {operation} this file." + elif "The caller does not have permission" in error_msg or "Forbidden" in error_msg: + if operation in ["write", "modify", "update", "append", "clear"]: + return "Permission denied. You don't have edit access to this spreadsheet. Make sure it's shared with edit permissions." + else: + return "Permission denied. You don't have access to this spreadsheet. Make sure it's shared with you and try re-selecting the file." + elif "not found" in error_msg.lower() or "does not exist" in error_msg.lower(): + return "Spreadsheet not found. The file may have been deleted or the link is invalid." + else: + return f"Failed to {operation} Google Sheet: {error_msg}" + + class SheetOperation(str, Enum): CREATE = "create" DELETE = "delete" @@ -216,18 +253,24 @@ class GoogleSheetsReadBlock(Block): credentials: GoogleCredentialsInput = GoogleCredentialsField( ["https://www.googleapis.com/auth/spreadsheets.readonly"] ) - spreadsheet_id: str = SchemaField( - description="The ID or URL of the spreadsheet to read from", - title="Spreadsheet ID or URL", + spreadsheet: GoogleDriveFile = GoogleDrivePickerField( + title="Spreadsheet", + description="Select a Google Sheets spreadsheet", + allowed_views=["SPREADSHEETS"], + allowed_mime_types=["application/vnd.google-apps.spreadsheet"], ) range: str = SchemaField( description="The A1 notation of the range to read", + placeholder="Sheet1!A1:Z1000", ) class Output(BlockSchemaOutput): result: list[list[str]] = SchemaField( description="The data read from the spreadsheet", ) + spreadsheet: GoogleDriveFile = SchemaField( + description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)", + ) error: str = SchemaField( description="Error message if any", ) @@ -241,9 +284,13 @@ class GoogleSheetsReadBlock(Block): output_schema=GoogleSheetsReadBlock.Output, disabled=GOOGLE_SHEETS_DISABLED, test_input={ - "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", - "range": "Sheet1!A1:B2", "credentials": TEST_CREDENTIALS_INPUT, + "spreadsheet": { + "id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "name": "Test Spreadsheet", + "mimeType": "application/vnd.google-apps.spreadsheet", + }, + "range": "Sheet1!A1:B2", }, test_credentials=TEST_CREDENTIALS, test_output=[ @@ -254,6 +301,17 @@ class GoogleSheetsReadBlock(Block): ["Alice", "85"], ], ), + ( + "spreadsheet", + GoogleDriveFile( + id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + name="Test Spreadsheet", + mimeType="application/vnd.google-apps.spreadsheet", + url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ), + ), ], test_mock={ "_read_sheet": lambda *args, **kwargs: [ @@ -266,16 +324,52 @@ class GoogleSheetsReadBlock(Block): async def run( self, input_data: Input, *, credentials: GoogleCredentials, **kwargs ) -> BlockOutput: - service = _build_sheets_service(credentials) - spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) - data = await asyncio.to_thread( - self._read_sheet, service, spreadsheet_id, input_data.range - ) - yield "result", data + if not input_data.spreadsheet: + yield "error", "No spreadsheet selected" + return + + # Check if the selected file is actually a Google Sheets spreadsheet + validation_error = _validate_spreadsheet_file(input_data.spreadsheet) + if validation_error: + yield "error", validation_error + return + try: + service = _build_sheets_service(credentials) + spreadsheet_id = input_data.spreadsheet.id + data = await asyncio.to_thread( + self._read_sheet, service, spreadsheet_id, input_data.range + ) + yield "result", data + # Output the GoogleDriveFile for chaining + yield "spreadsheet", GoogleDriveFile( + id=spreadsheet_id, + name=input_data.spreadsheet.name, + mimeType="application/vnd.google-apps.spreadsheet", + url=f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ) + except Exception as e: + yield "error", _handle_sheets_api_error(str(e), "read") def _read_sheet(self, service, spreadsheet_id: str, range: str) -> list[list[str]]: sheet = service.spreadsheets() - result = sheet.values().get(spreadsheetId=spreadsheet_id, range=range).execute() + range_to_use = range or "A:Z" + sheet_name, cell_range = parse_a1_notation(range_to_use) + if sheet_name: + cleaned_sheet = sheet_name.strip().strip("'\"") + formatted_sheet = format_sheet_name(cleaned_sheet) + cell_part = cell_range.strip() if cell_range else "" + if cell_part: + range_to_use = f"{formatted_sheet}!{cell_part}" + else: + range_to_use = f"{formatted_sheet}!A:Z" + # If no sheet name, keep the original range (e.g., "A1:B2" or "B:B") + result = ( + sheet.values() + .get(spreadsheetId=spreadsheet_id, range=range_to_use) + .execute() + ) return result.get("values", []) @@ -284,12 +378,15 @@ class GoogleSheetsWriteBlock(Block): credentials: GoogleCredentialsInput = GoogleCredentialsField( ["https://www.googleapis.com/auth/spreadsheets"] ) - spreadsheet_id: str = SchemaField( - description="The ID or URL of the spreadsheet to write to", - title="Spreadsheet ID or URL", + spreadsheet: GoogleDriveFile = GoogleDrivePickerField( + title="Spreadsheet", + description="Select a Google Sheets spreadsheet", + allowed_views=["SPREADSHEETS"], + allowed_mime_types=["application/vnd.google-apps.spreadsheet"], ) range: str = SchemaField( description="The A1 notation of the range to write", + placeholder="Sheet1!A1:B2", ) values: list[list[str]] = SchemaField( description="The data to write to the spreadsheet", @@ -299,6 +396,9 @@ class GoogleSheetsWriteBlock(Block): result: dict = SchemaField( description="The result of the write operation", ) + spreadsheet: GoogleDriveFile = SchemaField( + description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)", + ) error: str = SchemaField( description="Error message if any", ) @@ -312,13 +412,17 @@ class GoogleSheetsWriteBlock(Block): output_schema=GoogleSheetsWriteBlock.Output, disabled=GOOGLE_SHEETS_DISABLED, test_input={ - "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "credentials": TEST_CREDENTIALS_INPUT, + "spreadsheet": { + "id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "name": "Test Spreadsheet", + "mimeType": "application/vnd.google-apps.spreadsheet", + }, "range": "Sheet1!A1:B2", "values": [ ["Name", "Score"], ["Bob", "90"], ], - "credentials": TEST_CREDENTIALS_INPUT, }, test_credentials=TEST_CREDENTIALS, test_output=[ @@ -326,6 +430,17 @@ class GoogleSheetsWriteBlock(Block): "result", {"updatedCells": 4, "updatedColumns": 2, "updatedRows": 2}, ), + ( + "spreadsheet", + GoogleDriveFile( + id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + name="Test Spreadsheet", + mimeType="application/vnd.google-apps.spreadsheet", + url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ), + ), ], test_mock={ "_write_sheet": lambda *args, **kwargs: { @@ -339,16 +454,44 @@ class GoogleSheetsWriteBlock(Block): async def run( self, input_data: Input, *, credentials: GoogleCredentials, **kwargs ) -> BlockOutput: - service = _build_sheets_service(credentials) - spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) - result = await asyncio.to_thread( - self._write_sheet, - service, - spreadsheet_id, - input_data.range, - input_data.values, - ) - yield "result", result + if not input_data.spreadsheet: + yield "error", "No spreadsheet selected" + return + + # Check if the selected file is actually a Google Sheets spreadsheet + validation_error = _validate_spreadsheet_file(input_data.spreadsheet) + if validation_error: + # Customize message for write operations on CSV files + if "CSV file" in validation_error: + yield "error", validation_error.replace( + "Please use a CSV reader block instead, or", + "CSV files are read-only through Google Drive. Please", + ) + else: + yield "error", validation_error + return + + try: + service = _build_sheets_service(credentials) + result = await asyncio.to_thread( + self._write_sheet, + service, + input_data.spreadsheet.id, + input_data.range, + input_data.values, + ) + yield "result", result + # Output the GoogleDriveFile for chaining + yield "spreadsheet", GoogleDriveFile( + id=input_data.spreadsheet.id, + name=input_data.spreadsheet.name, + mimeType="application/vnd.google-apps.spreadsheet", + url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ) + except Exception as e: + yield "error", _handle_sheets_api_error(str(e), "write") def _write_sheet( self, service, spreadsheet_id: str, range: str, values: list[list[str]] @@ -373,9 +516,11 @@ class GoogleSheetsAppendBlock(Block): credentials: GoogleCredentialsInput = GoogleCredentialsField( ["https://www.googleapis.com/auth/spreadsheets"] ) - spreadsheet_id: str = SchemaField( - description="Spreadsheet ID or URL", - title="Spreadsheet ID or URL", + spreadsheet: GoogleDriveFile = GoogleDrivePickerField( + title="Spreadsheet", + description="Select a Google Sheets spreadsheet", + allowed_views=["SPREADSHEETS"], + allowed_mime_types=["application/vnd.google-apps.spreadsheet"], ) sheet_name: str = SchemaField( description="Optional sheet to append to (defaults to first sheet)", @@ -411,6 +556,12 @@ class GoogleSheetsAppendBlock(Block): class Output(BlockSchemaOutput): result: dict = SchemaField(description="Append API response") + spreadsheet: GoogleDriveFile = SchemaField( + description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)", + ) + error: str = SchemaField( + description="Error message if any", + ) def __init__(self): super().__init__( @@ -421,13 +572,28 @@ class GoogleSheetsAppendBlock(Block): output_schema=GoogleSheetsAppendBlock.Output, disabled=GOOGLE_SHEETS_DISABLED, test_input={ - "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", - "values": [["Charlie", "95"]], "credentials": TEST_CREDENTIALS_INPUT, + "spreadsheet": { + "id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "name": "Test Spreadsheet", + "mimeType": "application/vnd.google-apps.spreadsheet", + }, + "values": [["Charlie", "95"]], }, test_credentials=TEST_CREDENTIALS, test_output=[ ("result", {"updatedCells": 2, "updatedColumns": 2, "updatedRows": 1}), + ( + "spreadsheet", + GoogleDriveFile( + id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + name="Test Spreadsheet", + mimeType="application/vnd.google-apps.spreadsheet", + url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ), + ), ], test_mock={ "_append_sheet": lambda *args, **kwargs: { @@ -441,37 +607,58 @@ class GoogleSheetsAppendBlock(Block): async def run( self, input_data: Input, *, credentials: GoogleCredentials, **kwargs ) -> BlockOutput: - service = _build_sheets_service(credentials) - spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) - # Determine which values to use and convert if needed - processed_values: list[list[str]] + if not input_data.spreadsheet: + yield "error", "No spreadsheet selected" + return - # Validate that only one format is provided - if input_data.values and input_data.dict_values: - raise ValueError("Provide either 'values' or 'dict_values', not both") + # Check if the selected file is actually a Google Sheets spreadsheet + validation_error = _validate_spreadsheet_file(input_data.spreadsheet) + if validation_error: + yield "error", validation_error + return + try: + service = _build_sheets_service(credentials) - if input_data.dict_values: - if not input_data.headers: - raise ValueError("Headers are required when using dict_values") - processed_values = _convert_dicts_to_rows( - input_data.dict_values, input_data.headers + # Determine which values to use and convert if needed + processed_values: list[list[str]] + + # Validate that only one format is provided + if input_data.values and input_data.dict_values: + raise ValueError("Provide either 'values' or 'dict_values', not both") + + if input_data.dict_values: + if not input_data.headers: + raise ValueError("Headers are required when using dict_values") + processed_values = _convert_dicts_to_rows( + input_data.dict_values, input_data.headers + ) + elif input_data.values: + processed_values = input_data.values + else: + raise ValueError("Either 'values' or 'dict_values' must be provided") + + result = await asyncio.to_thread( + self._append_sheet, + service, + input_data.spreadsheet.id, + input_data.sheet_name, + processed_values, + input_data.range, + input_data.value_input_option, + input_data.insert_data_option, ) - elif input_data.values: - processed_values = input_data.values - else: - raise ValueError("Either 'values' or 'dict_values' must be provided") - - result = await asyncio.to_thread( - self._append_sheet, - service, - spreadsheet_id, - input_data.sheet_name, - processed_values, - input_data.range, - input_data.value_input_option, - input_data.insert_data_option, - ) - yield "result", result + yield "result", result + # Output the GoogleDriveFile for chaining + yield "spreadsheet", GoogleDriveFile( + id=input_data.spreadsheet.id, + name=input_data.spreadsheet.name, + mimeType="application/vnd.google-apps.spreadsheet", + url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ) + except Exception as e: + yield "error", f"Failed to append to Google Sheet: {str(e)}" def _append_sheet( self, @@ -512,18 +699,24 @@ class GoogleSheetsClearBlock(Block): credentials: GoogleCredentialsInput = GoogleCredentialsField( ["https://www.googleapis.com/auth/spreadsheets"] ) - spreadsheet_id: str = SchemaField( - description="The ID or URL of the spreadsheet to clear", - title="Spreadsheet ID or URL", + spreadsheet: GoogleDriveFile = GoogleDrivePickerField( + title="Spreadsheet", + description="Select a Google Sheets spreadsheet", + allowed_views=["SPREADSHEETS"], + allowed_mime_types=["application/vnd.google-apps.spreadsheet"], ) range: str = SchemaField( description="The A1 notation of the range to clear", + placeholder="Sheet1!A1:B2", ) class Output(BlockSchemaOutput): result: dict = SchemaField( description="The result of the clear operation", ) + spreadsheet: GoogleDriveFile = SchemaField( + description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)", + ) error: str = SchemaField( description="Error message if any", ) @@ -537,13 +730,28 @@ class GoogleSheetsClearBlock(Block): output_schema=GoogleSheetsClearBlock.Output, disabled=GOOGLE_SHEETS_DISABLED, test_input={ - "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", - "range": "Sheet1!A1:B2", "credentials": TEST_CREDENTIALS_INPUT, + "spreadsheet": { + "id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "name": "Test Spreadsheet", + "mimeType": "application/vnd.google-apps.spreadsheet", + }, + "range": "Sheet1!A1:B2", }, test_credentials=TEST_CREDENTIALS, test_output=[ ("result", {"clearedRange": "Sheet1!A1:B2"}), + ( + "spreadsheet", + GoogleDriveFile( + id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + name="Test Spreadsheet", + mimeType="application/vnd.google-apps.spreadsheet", + url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ), + ), ], test_mock={ "_clear_range": lambda *args, **kwargs: { @@ -555,15 +763,36 @@ class GoogleSheetsClearBlock(Block): async def run( self, input_data: Input, *, credentials: GoogleCredentials, **kwargs ) -> BlockOutput: - service = _build_sheets_service(credentials) - spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) - result = await asyncio.to_thread( - self._clear_range, - service, - spreadsheet_id, - input_data.range, - ) - yield "result", result + if not input_data.spreadsheet: + yield "error", "No spreadsheet selected" + return + + # Check if the selected file is actually a Google Sheets spreadsheet + validation_error = _validate_spreadsheet_file(input_data.spreadsheet) + if validation_error: + yield "error", validation_error + return + + try: + service = _build_sheets_service(credentials) + result = await asyncio.to_thread( + self._clear_range, + service, + input_data.spreadsheet.id, + input_data.range, + ) + yield "result", result + # Output the GoogleDriveFile for chaining + yield "spreadsheet", GoogleDriveFile( + id=input_data.spreadsheet.id, + name=input_data.spreadsheet.name, + mimeType="application/vnd.google-apps.spreadsheet", + url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ) + except Exception as e: + yield "error", f"Failed to clear Google Sheet range: {str(e)}" def _clear_range(self, service, spreadsheet_id: str, range: str) -> dict: result = ( @@ -580,15 +809,20 @@ class GoogleSheetsMetadataBlock(Block): credentials: GoogleCredentialsInput = GoogleCredentialsField( ["https://www.googleapis.com/auth/spreadsheets.readonly"] ) - spreadsheet_id: str = SchemaField( - description="The ID or URL of the spreadsheet to get metadata for", - title="Spreadsheet ID or URL", + spreadsheet: GoogleDriveFile = GoogleDrivePickerField( + title="Spreadsheet", + description="Select a Google Sheets spreadsheet", + allowed_views=["SPREADSHEETS"], + allowed_mime_types=["application/vnd.google-apps.spreadsheet"], ) class Output(BlockSchemaOutput): result: dict = SchemaField( description="The metadata of the spreadsheet including sheets info", ) + spreadsheet: GoogleDriveFile = SchemaField( + description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)", + ) error: str = SchemaField( description="Error message if any", ) @@ -602,8 +836,12 @@ class GoogleSheetsMetadataBlock(Block): output_schema=GoogleSheetsMetadataBlock.Output, disabled=GOOGLE_SHEETS_DISABLED, test_input={ - "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", "credentials": TEST_CREDENTIALS_INPUT, + "spreadsheet": { + "id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "name": "Test Spreadsheet", + "mimeType": "application/vnd.google-apps.spreadsheet", + }, }, test_credentials=TEST_CREDENTIALS, test_output=[ @@ -614,6 +852,17 @@ class GoogleSheetsMetadataBlock(Block): "sheets": [{"title": "Sheet1", "sheetId": 0}], }, ), + ( + "spreadsheet", + GoogleDriveFile( + id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + name="Test Spreadsheet", + mimeType="application/vnd.google-apps.spreadsheet", + url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ), + ), ], test_mock={ "_get_metadata": lambda *args, **kwargs: { @@ -626,14 +875,35 @@ class GoogleSheetsMetadataBlock(Block): async def run( self, input_data: Input, *, credentials: GoogleCredentials, **kwargs ) -> BlockOutput: - service = _build_sheets_service(credentials) - spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) - result = await asyncio.to_thread( - self._get_metadata, - service, - spreadsheet_id, - ) - yield "result", result + if not input_data.spreadsheet: + yield "error", "No spreadsheet selected" + return + + # Check if the selected file is actually a Google Sheets spreadsheet + validation_error = _validate_spreadsheet_file(input_data.spreadsheet) + if validation_error: + yield "error", validation_error + return + + try: + service = _build_sheets_service(credentials) + result = await asyncio.to_thread( + self._get_metadata, + service, + input_data.spreadsheet.id, + ) + yield "result", result + # Output the GoogleDriveFile for chaining + yield "spreadsheet", GoogleDriveFile( + id=input_data.spreadsheet.id, + name=input_data.spreadsheet.name, + mimeType="application/vnd.google-apps.spreadsheet", + url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ) + except Exception as e: + yield "error", f"Failed to get spreadsheet metadata: {str(e)}" def _get_metadata(self, service, spreadsheet_id: str) -> dict: result = ( @@ -661,9 +931,11 @@ class GoogleSheetsManageSheetBlock(Block): credentials: GoogleCredentialsInput = GoogleCredentialsField( ["https://www.googleapis.com/auth/spreadsheets"] ) - spreadsheet_id: str = SchemaField( - description="Spreadsheet ID or URL", - title="Spreadsheet ID or URL", + spreadsheet: GoogleDriveFile = GoogleDrivePickerField( + title="Spreadsheet", + description="Select a Google Sheets spreadsheet", + allowed_views=["SPREADSHEETS"], + allowed_mime_types=["application/vnd.google-apps.spreadsheet"], ) operation: SheetOperation = SchemaField(description="Operation to perform") sheet_name: str = SchemaField( @@ -679,6 +951,12 @@ class GoogleSheetsManageSheetBlock(Block): class Output(BlockSchemaOutput): result: dict = SchemaField(description="Operation result") + spreadsheet: GoogleDriveFile = SchemaField( + description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)", + ) + error: str = SchemaField( + description="Error message if any", + ) def __init__(self): super().__init__( @@ -689,13 +967,30 @@ class GoogleSheetsManageSheetBlock(Block): output_schema=GoogleSheetsManageSheetBlock.Output, disabled=GOOGLE_SHEETS_DISABLED, test_input={ - "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "credentials": TEST_CREDENTIALS_INPUT, + "spreadsheet": { + "id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "name": "Test Spreadsheet", + "mimeType": "application/vnd.google-apps.spreadsheet", + }, "operation": SheetOperation.CREATE, "sheet_name": "NewSheet", - "credentials": TEST_CREDENTIALS_INPUT, }, test_credentials=TEST_CREDENTIALS, - test_output=[("result", {"success": True, "sheetId": 123})], + test_output=[ + ("result", {"success": True, "sheetId": 123}), + ( + "spreadsheet", + GoogleDriveFile( + id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + name="Test Spreadsheet", + mimeType="application/vnd.google-apps.spreadsheet", + url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ), + ), + ], test_mock={ "_manage_sheet": lambda *args, **kwargs: { "success": True, @@ -707,18 +1002,39 @@ class GoogleSheetsManageSheetBlock(Block): async def run( self, input_data: Input, *, credentials: GoogleCredentials, **kwargs ) -> BlockOutput: - service = _build_sheets_service(credentials) - spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) - result = await asyncio.to_thread( - self._manage_sheet, - service, - spreadsheet_id, - input_data.operation, - input_data.sheet_name, - input_data.source_sheet_id, - input_data.destination_sheet_name, - ) - yield "result", result + if not input_data.spreadsheet: + yield "error", "No spreadsheet selected" + return + + # Check if the selected file is actually a Google Sheets spreadsheet + validation_error = _validate_spreadsheet_file(input_data.spreadsheet) + if validation_error: + yield "error", validation_error + return + + try: + service = _build_sheets_service(credentials) + result = await asyncio.to_thread( + self._manage_sheet, + service, + input_data.spreadsheet.id, + input_data.operation, + input_data.sheet_name, + input_data.source_sheet_id, + input_data.destination_sheet_name, + ) + yield "result", result + # Output the GoogleDriveFile for chaining + yield "spreadsheet", GoogleDriveFile( + id=input_data.spreadsheet.id, + name=input_data.spreadsheet.name, + mimeType="application/vnd.google-apps.spreadsheet", + url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ) + except Exception as e: + yield "error", f"Failed to manage sheet: {str(e)}" def _manage_sheet( self, @@ -731,17 +1047,21 @@ class GoogleSheetsManageSheetBlock(Block): ) -> dict: requests = [] - # Ensure a target sheet name when needed - target_name = resolve_sheet_name(service, spreadsheet_id, sheet_name) - if operation == SheetOperation.CREATE: + # For CREATE, use sheet_name directly or default to "New Sheet" + target_name = sheet_name or "New Sheet" requests.append({"addSheet": {"properties": {"title": target_name}}}) elif operation == SheetOperation.DELETE: + # For DELETE, resolve sheet name (fall back to first sheet if empty) + target_name = resolve_sheet_name( + service, spreadsheet_id, sheet_name or None + ) sid = sheet_id_by_name(service, spreadsheet_id, target_name) if sid is None: return {"error": f"Sheet '{target_name}' not found"} requests.append({"deleteSheet": {"sheetId": sid}}) elif operation == SheetOperation.COPY: + # For COPY, use source_sheet_id and destination_sheet_name directly requests.append( { "duplicateSheet": { @@ -768,9 +1088,11 @@ class GoogleSheetsBatchOperationsBlock(Block): credentials: GoogleCredentialsInput = GoogleCredentialsField( ["https://www.googleapis.com/auth/spreadsheets"] ) - spreadsheet_id: str = SchemaField( - description="The ID or URL of the spreadsheet to perform batch operations on", - title="Spreadsheet ID or URL", + spreadsheet: GoogleDriveFile = GoogleDrivePickerField( + title="Spreadsheet", + description="Select a Google Sheets spreadsheet", + allowed_views=["SPREADSHEETS"], + allowed_mime_types=["application/vnd.google-apps.spreadsheet"], ) operations: list[BatchOperation] = SchemaField( description="List of operations to perform", @@ -780,6 +1102,9 @@ class GoogleSheetsBatchOperationsBlock(Block): result: dict = SchemaField( description="The result of the batch operations", ) + spreadsheet: GoogleDriveFile = SchemaField( + description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)", + ) error: str = SchemaField( description="Error message if any", ) @@ -793,7 +1118,12 @@ class GoogleSheetsBatchOperationsBlock(Block): output_schema=GoogleSheetsBatchOperationsBlock.Output, disabled=GOOGLE_SHEETS_DISABLED, test_input={ - "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "credentials": TEST_CREDENTIALS_INPUT, + "spreadsheet": { + "id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "name": "Test Spreadsheet", + "mimeType": "application/vnd.google-apps.spreadsheet", + }, "operations": [ { "type": BatchOperationType.UPDATE, @@ -806,11 +1136,21 @@ class GoogleSheetsBatchOperationsBlock(Block): "values": [["Data1", "Data2"]], }, ], - "credentials": TEST_CREDENTIALS_INPUT, }, test_credentials=TEST_CREDENTIALS, test_output=[ ("result", {"totalUpdatedCells": 4, "replies": []}), + ( + "spreadsheet", + GoogleDriveFile( + id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + name="Test Spreadsheet", + mimeType="application/vnd.google-apps.spreadsheet", + url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ), + ), ], test_mock={ "_batch_operations": lambda *args, **kwargs: { @@ -823,15 +1163,35 @@ class GoogleSheetsBatchOperationsBlock(Block): async def run( self, input_data: Input, *, credentials: GoogleCredentials, **kwargs ) -> BlockOutput: - service = _build_sheets_service(credentials) - spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) - result = await asyncio.to_thread( - self._batch_operations, - service, - spreadsheet_id, - input_data.operations, - ) - yield "result", result + if not input_data.spreadsheet: + yield "error", "No spreadsheet selected" + return + + # Check if the selected file is actually a Google Sheets spreadsheet + validation_error = _validate_spreadsheet_file(input_data.spreadsheet) + if validation_error: + yield "error", validation_error + return + + try: + service = _build_sheets_service(credentials) + result = await asyncio.to_thread( + self._batch_operations, + service, + input_data.spreadsheet.id, + input_data.operations, + ) + yield "result", result + yield "spreadsheet", GoogleDriveFile( + id=input_data.spreadsheet.id, + name=input_data.spreadsheet.name, + mimeType="application/vnd.google-apps.spreadsheet", + url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ) + except Exception as e: + yield "error", f"Failed to perform batch operations: {str(e)}" def _batch_operations( self, service, spreadsheet_id: str, operations: list[BatchOperation] @@ -885,9 +1245,11 @@ class GoogleSheetsFindReplaceBlock(Block): credentials: GoogleCredentialsInput = GoogleCredentialsField( ["https://www.googleapis.com/auth/spreadsheets"] ) - spreadsheet_id: str = SchemaField( - description="The ID or URL of the spreadsheet to perform find/replace on", - title="Spreadsheet ID or URL", + spreadsheet: GoogleDriveFile = GoogleDrivePickerField( + title="Spreadsheet", + description="Select a Google Sheets spreadsheet", + allowed_views=["SPREADSHEETS"], + allowed_mime_types=["application/vnd.google-apps.spreadsheet"], ) find_text: str = SchemaField( description="The text to find", @@ -912,6 +1274,9 @@ class GoogleSheetsFindReplaceBlock(Block): result: dict = SchemaField( description="The result of the find/replace operation including number of replacements", ) + spreadsheet: GoogleDriveFile = SchemaField( + description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)", + ) error: str = SchemaField( description="Error message if any", ) @@ -925,16 +1290,31 @@ class GoogleSheetsFindReplaceBlock(Block): output_schema=GoogleSheetsFindReplaceBlock.Output, disabled=GOOGLE_SHEETS_DISABLED, test_input={ - "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "credentials": TEST_CREDENTIALS_INPUT, + "spreadsheet": { + "id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "name": "Test Spreadsheet", + "mimeType": "application/vnd.google-apps.spreadsheet", + }, "find_text": "old_value", "replace_text": "new_value", "match_case": False, "match_entire_cell": False, - "credentials": TEST_CREDENTIALS_INPUT, }, test_credentials=TEST_CREDENTIALS, test_output=[ ("result", {"occurrencesChanged": 5}), + ( + "spreadsheet", + GoogleDriveFile( + id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + name="Test Spreadsheet", + mimeType="application/vnd.google-apps.spreadsheet", + url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ), + ), ], test_mock={ "_find_replace": lambda *args, **kwargs: {"occurrencesChanged": 5}, @@ -944,19 +1324,39 @@ class GoogleSheetsFindReplaceBlock(Block): async def run( self, input_data: Input, *, credentials: GoogleCredentials, **kwargs ) -> BlockOutput: - service = _build_sheets_service(credentials) - spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) - result = await asyncio.to_thread( - self._find_replace, - service, - spreadsheet_id, - input_data.find_text, - input_data.replace_text, - input_data.sheet_id, - input_data.match_case, - input_data.match_entire_cell, - ) - yield "result", result + if not input_data.spreadsheet: + yield "error", "No spreadsheet selected" + return + + # Check if the selected file is actually a Google Sheets spreadsheet + validation_error = _validate_spreadsheet_file(input_data.spreadsheet) + if validation_error: + yield "error", validation_error + return + + try: + service = _build_sheets_service(credentials) + result = await asyncio.to_thread( + self._find_replace, + service, + input_data.spreadsheet.id, + input_data.find_text, + input_data.replace_text, + input_data.sheet_id, + input_data.match_case, + input_data.match_entire_cell, + ) + yield "result", result + yield "spreadsheet", GoogleDriveFile( + id=input_data.spreadsheet.id, + name=input_data.spreadsheet.name, + mimeType="application/vnd.google-apps.spreadsheet", + url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ) + except Exception as e: + yield "error", f"Failed to find/replace in Google Sheet: {str(e)}" def _find_replace( self, @@ -995,9 +1395,11 @@ class GoogleSheetsFindBlock(Block): credentials: GoogleCredentialsInput = GoogleCredentialsField( ["https://www.googleapis.com/auth/spreadsheets.readonly"] ) - spreadsheet_id: str = SchemaField( - description="The ID or URL of the spreadsheet to search in", - title="Spreadsheet ID or URL", + spreadsheet: GoogleDriveFile = GoogleDrivePickerField( + title="Spreadsheet", + description="Select a Google Sheets spreadsheet", + allowed_views=["SPREADSHEETS"], + allowed_mime_types=["application/vnd.google-apps.spreadsheet"], ) find_text: str = SchemaField( description="The text to find", @@ -1034,6 +1436,9 @@ class GoogleSheetsFindBlock(Block): count: int = SchemaField( description="Number of occurrences found", ) + spreadsheet: GoogleDriveFile = SchemaField( + description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)", + ) error: str = SchemaField( description="Error message if any", ) @@ -1047,13 +1452,17 @@ class GoogleSheetsFindBlock(Block): output_schema=GoogleSheetsFindBlock.Output, disabled=GOOGLE_SHEETS_DISABLED, test_input={ - "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "credentials": TEST_CREDENTIALS_INPUT, + "spreadsheet": { + "id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "name": "Test Spreadsheet", + "mimeType": "application/vnd.google-apps.spreadsheet", + }, "find_text": "search_value", "match_case": False, "match_entire_cell": False, "find_all": True, "range": "Sheet1!A1:C10", - "credentials": TEST_CREDENTIALS_INPUT, }, test_credentials=TEST_CREDENTIALS, test_output=[ @@ -1067,6 +1476,17 @@ class GoogleSheetsFindBlock(Block): ], ), ("result", {"success": True}), + ( + "spreadsheet", + GoogleDriveFile( + id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + name="Test Spreadsheet", + mimeType="application/vnd.google-apps.spreadsheet", + url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ), + ), ], test_mock={ "_find_text": lambda *args, **kwargs: { @@ -1083,22 +1503,42 @@ class GoogleSheetsFindBlock(Block): async def run( self, input_data: Input, *, credentials: GoogleCredentials, **kwargs ) -> BlockOutput: - service = _build_sheets_service(credentials) - spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) - result = await asyncio.to_thread( - self._find_text, - service, - spreadsheet_id, - input_data.find_text, - input_data.sheet_id, - input_data.match_case, - input_data.match_entire_cell, - input_data.find_all, - input_data.range, - ) - yield "count", result["count"] - yield "locations", result["locations"] - yield "result", {"success": True} + if not input_data.spreadsheet: + yield "error", "No spreadsheet selected" + return + + # Check if the selected file is actually a Google Sheets spreadsheet + validation_error = _validate_spreadsheet_file(input_data.spreadsheet) + if validation_error: + yield "error", validation_error + return + + try: + service = _build_sheets_service(credentials) + result = await asyncio.to_thread( + self._find_text, + service, + input_data.spreadsheet.id, + input_data.find_text, + input_data.sheet_id, + input_data.match_case, + input_data.match_entire_cell, + input_data.find_all, + input_data.range, + ) + yield "count", result["count"] + yield "locations", result["locations"] + yield "result", {"success": True} + yield "spreadsheet", GoogleDriveFile( + id=input_data.spreadsheet.id, + name=input_data.spreadsheet.name, + mimeType="application/vnd.google-apps.spreadsheet", + url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ) + except Exception as e: + yield "error", f"Failed to find text in Google Sheet: {str(e)}" def _find_text( self, @@ -1263,11 +1703,16 @@ class GoogleSheetsFormatBlock(Block): credentials: GoogleCredentialsInput = GoogleCredentialsField( ["https://www.googleapis.com/auth/spreadsheets"] ) - spreadsheet_id: str = SchemaField( - description="Spreadsheet ID or URL", - title="Spreadsheet ID or URL", + spreadsheet: GoogleDriveFile = GoogleDrivePickerField( + title="Spreadsheet", + description="Select a Google Sheets spreadsheet", + allowed_views=["SPREADSHEETS"], + allowed_mime_types=["application/vnd.google-apps.spreadsheet"], + ) + range: str = SchemaField( + description="A1 notation – sheet optional", + placeholder="Sheet1!A1:B2", ) - range: str = SchemaField(description="A1 notation – sheet optional") background_color: dict = SchemaField(default={}) text_color: dict = SchemaField(default={}) bold: bool = SchemaField(default=False) @@ -1276,6 +1721,12 @@ class GoogleSheetsFormatBlock(Block): class Output(BlockSchemaOutput): result: dict = SchemaField(description="API response or success flag") + spreadsheet: GoogleDriveFile = SchemaField( + description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)", + ) + error: str = SchemaField( + description="Error message if any", + ) def __init__(self): super().__init__( @@ -1286,37 +1737,74 @@ class GoogleSheetsFormatBlock(Block): output_schema=GoogleSheetsFormatBlock.Output, disabled=GOOGLE_SHEETS_DISABLED, test_input={ - "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "credentials": TEST_CREDENTIALS_INPUT, + "spreadsheet": { + "id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "name": "Test Spreadsheet", + "mimeType": "application/vnd.google-apps.spreadsheet", + }, "range": "A1:B2", "background_color": {"red": 1.0, "green": 0.9, "blue": 0.9}, "bold": True, - "credentials": TEST_CREDENTIALS_INPUT, }, test_credentials=TEST_CREDENTIALS, - test_output=[("result", {"success": True})], + test_output=[ + ("result", {"success": True}), + ( + "spreadsheet", + GoogleDriveFile( + id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + name="Test Spreadsheet", + mimeType="application/vnd.google-apps.spreadsheet", + url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ), + ), + ], test_mock={"_format_cells": lambda *args, **kwargs: {"success": True}}, ) async def run( self, input_data: Input, *, credentials: GoogleCredentials, **kwargs ) -> BlockOutput: - service = _build_sheets_service(credentials) - spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) - result = await asyncio.to_thread( - self._format_cells, - service, - spreadsheet_id, - input_data.range, - input_data.background_color, - input_data.text_color, - input_data.bold, - input_data.italic, - input_data.font_size, - ) - if "error" in result: - yield "error", result["error"] - else: - yield "result", result + if not input_data.spreadsheet: + yield "error", "No spreadsheet selected" + return + + # Check if the selected file is actually a Google Sheets spreadsheet + validation_error = _validate_spreadsheet_file(input_data.spreadsheet) + if validation_error: + yield "error", validation_error + return + + try: + service = _build_sheets_service(credentials) + result = await asyncio.to_thread( + self._format_cells, + service, + input_data.spreadsheet.id, + input_data.range, + input_data.background_color, + input_data.text_color, + input_data.bold, + input_data.italic, + input_data.font_size, + ) + if "error" in result: + yield "error", result["error"] + else: + yield "result", result + yield "spreadsheet", GoogleDriveFile( + id=input_data.spreadsheet.id, + name=input_data.spreadsheet.name, + mimeType="application/vnd.google-apps.spreadsheet", + url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ) + except Exception as e: + yield "error", f"Failed to format Google Sheet cells: {str(e)}" def _format_cells( self, @@ -1402,6 +1890,9 @@ class GoogleSheetsCreateSpreadsheetBlock(Block): result: dict = SchemaField( description="The result containing spreadsheet ID and URL", ) + spreadsheet: GoogleDriveFile = SchemaField( + description="The created spreadsheet as a GoogleDriveFile (for chaining to other blocks)", + ) spreadsheet_id: str = SchemaField( description="The ID of the created spreadsheet", ) @@ -1427,6 +1918,17 @@ class GoogleSheetsCreateSpreadsheetBlock(Block): }, test_credentials=TEST_CREDENTIALS, test_output=[ + ( + "spreadsheet", + GoogleDriveFile( + id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + name="Test Spreadsheet", + mimeType="application/vnd.google-apps.spreadsheet", + url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ), + ), ("spreadsheet_id", "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms"), ( "spreadsheet_url", @@ -1438,6 +1940,7 @@ class GoogleSheetsCreateSpreadsheetBlock(Block): "_create_spreadsheet": lambda *args, **kwargs: { "spreadsheetId": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", "spreadsheetUrl": "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit", + "title": "Test Spreadsheet", }, }, ) @@ -1456,8 +1959,19 @@ class GoogleSheetsCreateSpreadsheetBlock(Block): if "error" in result: yield "error", result["error"] else: - yield "spreadsheet_id", result["spreadsheetId"] - yield "spreadsheet_url", result["spreadsheetUrl"] + spreadsheet_id = result["spreadsheetId"] + spreadsheet_url = result["spreadsheetUrl"] + # Output the full GoogleDriveFile object for easy chaining + yield "spreadsheet", GoogleDriveFile( + id=spreadsheet_id, + name=result.get("title", input_data.title), + mimeType="application/vnd.google-apps.spreadsheet", + url=spreadsheet_url, + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ) + yield "spreadsheet_id", spreadsheet_id + yield "spreadsheet_url", spreadsheet_url yield "result", {"success": True} def _create_spreadsheet(self, service, title: str, sheet_names: list[str]) -> dict: @@ -1493,6 +2007,152 @@ class GoogleSheetsCreateSpreadsheetBlock(Block): return { "spreadsheetId": spreadsheet_id, "spreadsheetUrl": spreadsheet_url, + "title": title, } except Exception as e: return {"error": str(e)} + + +class GoogleSheetsUpdateCellBlock(Block): + """Update a single cell in a Google Sheets spreadsheet.""" + + class Input(BlockSchemaInput): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + ["https://www.googleapis.com/auth/spreadsheets"] + ) + spreadsheet: GoogleDriveFile = GoogleDrivePickerField( + title="Spreadsheet", + description="Select a Google Sheets spreadsheet", + allowed_views=["SPREADSHEETS"], + allowed_mime_types=["application/vnd.google-apps.spreadsheet"], + ) + cell: str = SchemaField( + description="Cell address in A1 notation (e.g., 'A1', 'Sheet1!B2')", + placeholder="A1", + ) + value: str = SchemaField( + description="Value to write to the cell", + ) + value_input_option: ValueInputOption = SchemaField( + description="How input data should be interpreted", + default=ValueInputOption.USER_ENTERED, + advanced=True, + ) + + class Output(BlockSchemaOutput): + result: dict = SchemaField( + description="The result of the update operation", + ) + spreadsheet: GoogleDriveFile = SchemaField( + description="The spreadsheet as a GoogleDriveFile (for chaining to other blocks)", + ) + error: str = SchemaField( + description="Error message if any", + ) + + def __init__(self): + super().__init__( + id="df521b68-62d9-42e4-924f-fb6c245516fc", + description="Update a single cell in a Google Sheets spreadsheet.", + categories={BlockCategory.DATA}, + input_schema=GoogleSheetsUpdateCellBlock.Input, + output_schema=GoogleSheetsUpdateCellBlock.Output, + disabled=GOOGLE_SHEETS_DISABLED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "spreadsheet": { + "id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "name": "Test Spreadsheet", + "mimeType": "application/vnd.google-apps.spreadsheet", + }, + "cell": "A1", + "value": "Hello World", + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "result", + {"updatedCells": 1, "updatedColumns": 1, "updatedRows": 1}, + ), + ( + "spreadsheet", + GoogleDriveFile( + id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + name="Test Spreadsheet", + mimeType="application/vnd.google-apps.spreadsheet", + url="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ), + ), + ], + test_mock={ + "_update_cell": lambda *args, **kwargs: { + "updatedCells": 1, + "updatedColumns": 1, + "updatedRows": 1, + }, + }, + ) + + async def run( + self, input_data: Input, *, credentials: GoogleCredentials, **kwargs + ) -> BlockOutput: + try: + if not input_data.spreadsheet: + yield "error", "No spreadsheet selected" + return + + # Check if the selected file is actually a Google Sheets spreadsheet + validation_error = _validate_spreadsheet_file(input_data.spreadsheet) + if validation_error: + yield "error", validation_error + return + + service = _build_sheets_service(credentials) + result = await asyncio.to_thread( + self._update_cell, + service, + input_data.spreadsheet.id, + input_data.cell, + input_data.value, + input_data.value_input_option, + ) + + yield "result", result + yield "spreadsheet", GoogleDriveFile( + id=input_data.spreadsheet.id, + name=input_data.spreadsheet.name, + mimeType="application/vnd.google-apps.spreadsheet", + url=f"https://docs.google.com/spreadsheets/d/{input_data.spreadsheet.id}/edit", + iconUrl="https://www.gstatic.com/images/branding/product/1x/sheets_48dp.png", + isFolder=False, + ) + except Exception as e: + yield "error", _handle_sheets_api_error(str(e), "update") + + def _update_cell( + self, + service, + spreadsheet_id: str, + cell: str, + value: str, + value_input_option: ValueInputOption, + ) -> dict: + body = {"values": [[value]]} + result = ( + service.spreadsheets() + .values() + .update( + spreadsheetId=spreadsheet_id, + range=cell, + valueInputOption=value_input_option.value, + body=body, + ) + .execute() + ) + return { + "updatedCells": result.get("updatedCells", 0), + "updatedRows": result.get("updatedRows", 0), + "updatedColumns": result.get("updatedColumns", 0), + } diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode.tsx index f3e9d95b90..4c3229fc12 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode.tsx @@ -1,70 +1,67 @@ -import React, { - useState, - useEffect, - useCallback, - useRef, - useContext, -} from "react"; -import Link from "next/link"; -import { NodeProps, useReactFlow, Node as XYNode, Edge } from "@xyflow/react"; -import "@xyflow/react/dist/style.css"; -import "./customnode.css"; -import InputModalComponent from "../InputModalComponent"; -import OutputModalComponent from "../OutputModalComponent"; -import { - BlockIORootSchema, - BlockIOSubSchema, - BlockIOStringSubSchema, - Category, - NodeExecutionResult, - BlockUIType, - BlockCost, -} from "@/lib/autogpt-server-api"; -import { - beautifyString, - cn, - fillObjectDefaultsFromSchema, - getValue, - hasNonNullNonObjectValue, - isObject, - parseKeys, - setNestedProperty, -} from "@/lib/utils"; -import { Button } from "@/components/atoms/Button/Button"; -import { TextRenderer } from "@/components/__legacy__/ui/render"; -import { history } from "../history"; -import NodeHandle from "../NodeHandle"; -import { NodeGenericInputField, NodeTextBoxInput } from "../NodeInputs"; -import { getPrimaryCategoryColor } from "@/lib/utils"; -import { BuilderContext } from "../Flow/Flow"; -import { Badge } from "../../../../../../components/__legacy__/ui/badge"; -import NodeOutputs from "../NodeOutputs"; -import { IconCoin } from "../../../../../../components/__legacy__/ui/icons"; -import * as Separator from "@radix-ui/react-separator"; -import * as ContextMenu from "@radix-ui/react-context-menu"; -import { - Alert, - AlertDescription, -} from "../../../../../../components/molecules/Alert/Alert"; -import { - DotsVerticalIcon, - TrashIcon, - CopyIcon, - ExitIcon, - Pencil1Icon, -} from "@radix-ui/react-icons"; -import { InfoIcon, Key } from "@phosphor-icons/react"; -import useCredits from "@/hooks/useCredits"; import { getV1GetAyrshareSsoUrl } from "@/app/api/__generated__/endpoints/integrations/integrations"; -import { toast } from "@/components/molecules/Toast/use-toast"; import { Input } from "@/components/__legacy__/ui/input"; +import { TextRenderer } from "@/components/__legacy__/ui/render"; +import { Button } from "@/components/atoms/Button/Button"; +import { Switch } from "@/components/atoms/Switch/Switch"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/atoms/Tooltip/BaseTooltip"; import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip"; -import { Switch } from "@/components/atoms/Switch/Switch"; +import { toast } from "@/components/molecules/Toast/use-toast"; +import useCredits from "@/hooks/useCredits"; +import { + BlockCost, + BlockIORootSchema, + BlockIOStringSubSchema, + BlockIOSubSchema, + BlockUIType, + Category, + NodeExecutionResult, +} from "@/lib/autogpt-server-api"; +import { + beautifyString, + cn, + fillObjectDefaultsFromSchema, + getPrimaryCategoryColor, + getValue, + hasNonNullNonObjectValue, + isObject, + parseKeys, + setNestedProperty, +} from "@/lib/utils"; +import { InfoIcon, Key } from "@phosphor-icons/react"; +import * as ContextMenu from "@radix-ui/react-context-menu"; +import { + CopyIcon, + DotsVerticalIcon, + ExitIcon, + Pencil1Icon, + TrashIcon, +} from "@radix-ui/react-icons"; +import * as Separator from "@radix-ui/react-separator"; +import { Edge, NodeProps, useReactFlow, Node as XYNode } from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import Link from "next/link"; +import React, { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { Badge } from "@/components/__legacy__/ui/badge"; +import { IconCoin } from "@/components/__legacy__/ui/icons"; +import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert"; +import { BuilderContext } from "../Flow/Flow"; +import { history } from "../history"; +import InputModalComponent from "../InputModalComponent"; +import NodeHandle from "../NodeHandle"; +import { NodeGenericInputField, NodeTextBoxInput } from "../NodeInputs"; +import NodeOutputs from "../NodeOutputs"; +import OutputModalComponent from "../OutputModalComponent"; +import "./customnode.css"; export type ConnectionData = Array<{ edge_id: string; @@ -366,6 +363,7 @@ export const CustomNode = React.memo( // For OUTPUT blocks, only show the 'value' (hides 'name') input connection handle !(nodeType == BlockUIType.OUTPUT && propKey == "name"); const isConnected = isInputHandleConnected(propKey); + return ( !isHidden && (isRequired || isAdvancedOpen || isConnected || !isAdvanced) && ( diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeInputs.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeInputs.tsx index b050a1491f..6b01e07337 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeInputs.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeInputs.tsx @@ -1,18 +1,15 @@ +import { + ConnectionData, + CustomNodeData, +} from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode"; +import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/CredentialsInputs/CredentialsInputs"; import { Calendar } from "@/components/__legacy__/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/__legacy__/ui/popover"; -import { format } from "date-fns"; -import { CalendarIcon } from "lucide-react"; -import { beautifyString, cn } from "@/lib/utils"; -import { Node, useNodeId, useNodesData } from "@xyflow/react"; -import { - ConnectionData, - CustomNodeData, -} from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode"; -import { Cross2Icon, Pencil2Icon, PlusIcon } from "@radix-ui/react-icons"; +import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput"; import { BlockIOArraySubSchema, BlockIOBooleanSubSchema, @@ -29,22 +26,21 @@ import { DataType, determineDataType, } from "@/lib/autogpt-server-api/types"; +import { beautifyString, cn } from "@/lib/utils"; +import { Cross2Icon, Pencil2Icon, PlusIcon } from "@radix-ui/react-icons"; +import { Node, useNodeId, useNodesData } from "@xyflow/react"; +import { format } from "date-fns"; +import { CalendarIcon } from "lucide-react"; import React, { FC, useCallback, useEffect, useMemo, - useState, useRef, + useState, } from "react"; -import { Button } from "../../../../../components/__legacy__/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../../../../../components/__legacy__/ui/select"; +import { Button } from "@/components/__legacy__/ui/button"; +import { LocalValuedInput } from "@/components/__legacy__/ui/input"; import { MultiSelector, MultiSelectorContent, @@ -52,12 +48,17 @@ import { MultiSelectorItem, MultiSelectorList, MultiSelectorTrigger, -} from "../../../../../components/__legacy__/ui/multiselect"; -import { LocalValuedInput } from "../../../../../components/__legacy__/ui/input"; +} from "@/components/__legacy__/ui/multiselect"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/__legacy__/ui/select"; +import { Switch } from "@/components/atoms/Switch/Switch"; +import { NodeTableInput } from "@/components/node-table-input"; import NodeHandle from "./NodeHandle"; -import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/CredentialsInputs/CredentialsInputs"; -import { Switch } from "../../../../../components/atoms/Switch/Switch"; -import { NodeTableInput } from "../../../../../components/node-table-input"; type NodeObjectInputTreeProps = { nodeId: string; @@ -370,6 +371,22 @@ export const NodeGenericInputField: FC<{ handleInputChange={handleInputChange} /> ); + case DataType.GOOGLE_DRIVE_PICKER: { + const pickerSchema = propSchema as any; + const config: import("@/lib/autogpt-server-api/types").GoogleDrivePickerConfig = + pickerSchema.google_drive_picker_config || {}; + + return ( + handleInputChange(propKey, value)} + error={errors[propKey]} + className={className} + showRemoveButton={true} + /> + ); + } case DataType.DATE: case DataType.TIME: diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunAgentInputs/RunAgentInputs.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunAgentInputs/RunAgentInputs.tsx index dd2f328058..d98d3cb10d 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunAgentInputs/RunAgentInputs.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunAgentInputs/RunAgentInputs.tsx @@ -1,10 +1,15 @@ -import React from "react"; import { format } from "date-fns"; +import React from "react"; import { Input as DSInput } from "@/components/atoms/Input/Input"; import { Select as DSSelect } from "@/components/atoms/Select/Select"; import { MultiToggle } from "@/components/molecules/MultiToggle/MultiToggle"; // Removed shadcn Select usage in favor of DS Select for time picker +import { Button } from "@/components/atoms/Button/Button"; +import { FileInput } from "@/components/atoms/FileInput/FileInput"; +import { Switch } from "@/components/atoms/Switch/Switch"; +import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput"; +import { TimePicker } from "@/components/molecules/TimePicker/TimePicker"; import { BlockIOObjectSubSchema, BlockIOSubSchema, @@ -13,12 +18,8 @@ import { determineDataType, TableRow, } from "@/lib/autogpt-server-api/types"; -import { TimePicker } from "@/components/molecules/TimePicker/TimePicker"; -import { FileInput } from "@/components/atoms/FileInput/FileInput"; -import { useRunAgentInputs } from "./useRunAgentInputs"; -import { Switch } from "@/components/atoms/Switch/Switch"; import { PlusIcon, XIcon } from "@phosphor-icons/react"; -import { Button } from "@/components/atoms/Button/Button"; +import { useRunAgentInputs } from "./useRunAgentInputs"; /** * A generic prop structure for the TypeBasedInput. @@ -90,6 +91,23 @@ export function RunAgentInputs({ ); break; + case DataType.GOOGLE_DRIVE_PICKER: { + const pickerSchema = schema as any; + const config: import("@/lib/autogpt-server-api/types").GoogleDrivePickerConfig = + pickerSchema.google_drive_picker_config || {}; + + innerInputElement = ( + + ); + break; + } + case DataType.BOOLEAN: innerInputElement = ( <> diff --git a/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePicker.tsx b/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePicker.tsx index 2554a1c4ab..42b6707dc9 100644 --- a/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePicker.tsx +++ b/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePicker.tsx @@ -2,7 +2,7 @@ import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/CredentialsInputs/CredentialsInputs"; import { Button } from "@/components/atoms/Button/Button"; -import { CircleNotchIcon } from "@phosphor-icons/react"; +import { CircleNotchIcon, FolderOpenIcon } from "@phosphor-icons/react"; import { Props, useGoogleDrivePicker } from "./useGoogleDrivePicker"; export function GoogleDrivePicker(props: Props) { @@ -12,28 +12,46 @@ export function GoogleDrivePicker(props: Props) { isAuthInProgress, isLoading, handleOpenPicker, + selectedCredential, + setSelectedCredential, } = useGoogleDrivePicker(props); if (!credentials || credentials.isLoading) { return ; } - if (!hasGoogleOAuth) + if (!hasGoogleOAuth) { return ( {}} + selectedCredentials={selectedCredential} + onSelectCredentials={setSelectedCredential} hideIfSingleCredentialAvailable /> ); + } + + const hasMultipleCredentials = + credentials.savedCredentials && credentials.savedCredentials.length > 1; return ( - +
+ {hasMultipleCredentials && ( + + )} + +
); } diff --git a/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput.tsx b/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput.tsx new file mode 100644 index 0000000000..506b5b87b8 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput.tsx @@ -0,0 +1,137 @@ +import { Button } from "@/components/atoms/Button/Button"; +import { cn } from "@/lib/utils"; +import { Cross2Icon } from "@radix-ui/react-icons"; +import React, { useCallback } from "react"; +import { GoogleDrivePicker } from "./GoogleDrivePicker"; +import type { GoogleDrivePickerConfig } from "@/lib/autogpt-server-api/types"; + +export interface GoogleDrivePickerInputProps { + config: GoogleDrivePickerConfig; + value: any; + onChange: (value: any) => void; + error?: string; + className?: string; + showRemoveButton?: boolean; +} + +export function GoogleDrivePickerInput({ + config, + value, + onChange, + error, + className, + showRemoveButton = true, +}: GoogleDrivePickerInputProps) { + const [pickerError, setPickerError] = React.useState(null); + const isMultiSelect = config.multiselect || false; + const currentFiles = isMultiSelect + ? Array.isArray(value) + ? value + : [] + : value + ? [value] + : []; + + const handlePicked = useCallback( + (files: any[]) => { + // Clear any previous picker errors + setPickerError(null); + + // Convert to GoogleDriveFile format + const convertedFiles = files.map((f) => ({ + id: f.id, + name: f.name, + mimeType: f.mimeType, + url: f.url, + iconUrl: f.iconUrl, + isFolder: f.mimeType === "application/vnd.google-apps.folder", + })); + + // Store based on multiselect mode + const newValue = isMultiSelect ? convertedFiles : convertedFiles[0]; + onChange(newValue); + }, + [isMultiSelect, onChange], + ); + + const handleRemoveFile = useCallback( + (idx: number) => { + if (isMultiSelect) { + const newFiles = currentFiles.filter((_: any, i: number) => i !== idx); + onChange(newFiles); + } else { + onChange(null); + } + }, + [isMultiSelect, currentFiles, onChange], + ); + + const handleError = useCallback((error: any) => { + console.error("Google Drive Picker error:", error); + setPickerError(error instanceof Error ? error.message : String(error)); + }, []); + + return ( +
+ {/* Picker Button */} + { + // User canceled - no action needed + }} + onError={handleError} + /> + + {/* Display Selected Files */} + {currentFiles.length > 0 && ( +
+ {currentFiles.map((file: any, idx: number) => ( +
+
+ {file.iconUrl && ( + + )} + + {file.name || file.id} + +
+ + {showRemoveButton && ( + + )} +
+ ))} +
+ )} + + {/* Error Messages */} + {error && {error}} + {pickerError && ( + {pickerError} + )} +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/useGoogleDrivePicker.ts b/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/useGoogleDrivePicker.ts index 359d7e4596..323cd6d9d6 100644 --- a/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/useGoogleDrivePicker.ts +++ b/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/useGoogleDrivePicker.ts @@ -1,6 +1,10 @@ +import { getGetV1GetSpecificCredentialByIdQueryOptions } from "@/app/api/__generated__/endpoints/integrations/integrations"; +import type { OAuth2Credentials } from "@/app/api/__generated__/models/oAuth2Credentials"; import { useToast } from "@/components/molecules/Toast/use-toast"; import useCredentials from "@/hooks/useCredentials"; -import { useMemo, useRef, useState } from "react"; +import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState } from "react"; import { getCredentialsSchema, GooglePickerView, @@ -54,10 +58,15 @@ export function useGoogleDrivePicker(options: Props) { const requestedScopes = options?.scopes || defaultScopes; const [isLoading, setIsLoading] = useState(false); const [isAuthInProgress, setIsAuthInProgress] = useState(false); + const [hasInsufficientScopes, setHasInsufficientScopes] = useState(false); + const [selectedCredential, setSelectedCredential] = useState< + CredentialsMetaInput | undefined + >(); const accessTokenRef = useRef(null); const tokenClientRef = useRef(null); const pickerReadyRef = useRef(false); const credentials = useCredentials(getCredentialsSchema(requestedScopes)); + const queryClient = useQueryClient(); const isReady = pickerReadyRef.current && !!tokenClientRef.current; const { toast } = useToast(); @@ -66,10 +75,109 @@ export function useGoogleDrivePicker(options: Props) { return credentials.savedCredentials?.length > 0; }, [credentials]); + useEffect(() => { + if ( + hasGoogleOAuth && + credentials && + !credentials.isLoading && + credentials.savedCredentials?.length > 0 + ) { + setHasInsufficientScopes(false); + } + }, [hasGoogleOAuth, credentials]); + + useEffect(() => { + if ( + credentials && + !credentials.isLoading && + credentials.savedCredentials?.length === 1 && + !selectedCredential + ) { + setSelectedCredential({ + id: credentials.savedCredentials[0].id, + type: credentials.savedCredentials[0].type, + provider: credentials.savedCredentials[0].provider, + title: credentials.savedCredentials[0].title, + }); + } + }, [credentials, selectedCredential]); + async function openPicker() { try { await ensureLoaded(); - console.log(accessTokenRef.current); + + if ( + hasGoogleOAuth && + credentials && + !credentials.isLoading && + credentials.savedCredentials?.length > 0 + ) { + const credentialId = + selectedCredential?.id || credentials.savedCredentials[0].id; + + try { + const queryOptions = getGetV1GetSpecificCredentialByIdQueryOptions( + "google", + credentialId, + ); + + const response = await queryClient.fetchQuery(queryOptions); + + if (response.status === 200 && response.data) { + const cred = response.data; + if (cred.type === "oauth2") { + const oauthCred = cred as OAuth2Credentials; + if (oauthCred.access_token) { + const credentialScopes = new Set(oauthCred.scopes || []); + const requiredScopesSet = new Set(requestedScopes); + const hasRequiredScopes = Array.from(requiredScopesSet).every( + (scope) => credentialScopes.has(scope), + ); + + if (!hasRequiredScopes) { + const error = new Error( + "The saved Google OAuth credentials do not have the required permissions. Please sign in again with the correct permissions.", + ); + toast({ + title: "Insufficient Permissions", + description: error.message, + variant: "destructive", + }); + setHasInsufficientScopes(true); + if (onError) onError(error); + return; + } + + accessTokenRef.current = oauthCred.access_token; + buildAndShowPicker(oauthCred.access_token); + return; + } + } + } + + const error = new Error( + "Failed to retrieve Google OAuth credentials. Please try signing in again.", + ); + if (onError) onError(error); + return; + } catch (err) { + const error = + err instanceof Error + ? err + : new Error("Failed to fetch Google OAuth credentials"); + + toast({ + title: "Authentication Error", + description: error.message, + variant: "destructive", + }); + + if (onError) onError(error); + + return; + } + } + const token = accessTokenRef.current || (await requestAccessToken()); buildAndShowPicker(token); } catch (e) { @@ -195,6 +303,9 @@ export function useGoogleDrivePicker(options: Props) { isAuthInProgress, handleOpenPicker: openPicker, credentials, - hasGoogleOAuth, + hasGoogleOAuth: hasInsufficientScopes ? false : hasGoogleOAuth, + accessToken: accessTokenRef.current, + selectedCredential, + setSelectedCredential, }; } diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts index ea82e34d3e..283e342de7 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts @@ -80,6 +80,7 @@ export enum DataType { KEY_VALUE = "key-value", ARRAY = "array", TABLE = "table", + GOOGLE_DRIVE_PICKER = "google-drive-picker", } export type BlockIOSubSchemaMeta = { @@ -116,6 +117,43 @@ export type BlockIOArraySubSchema = BlockIOSubSchemaMeta & { secret?: boolean; }; +export type GoogleDriveFile = { + id: string; + name?: string; + mimeType?: string; + url?: string; + iconUrl?: string; + isFolder?: boolean; +}; + +/** Valid view types for Google Drive Picker - matches backend AttachmentView */ +export type AttachmentView = + | "DOCS" + | "DOCUMENTS" + | "SPREADSHEETS" + | "PRESENTATIONS" + | "DOCS_IMAGES" + | "FOLDERS"; + +export type GoogleDrivePickerConfig = { + multiselect?: boolean; + allow_folder_selection?: boolean; + allowed_views?: AttachmentView[]; + allowed_mime_types?: string[]; + scopes?: string[]; +}; + +/** + * Schema for Google Drive Picker input fields. + * When multiselect=false: type="object" (single GoogleDriveFile) + * When multiselect=true: type="array" with items={ type="object" } (array of GoogleDriveFile) + */ +export type GoogleDrivePickerSchema = BlockIOSubSchemaMeta & { + type: "object" | "array"; + format: "google-drive-picker"; + google_drive_picker_config?: GoogleDrivePickerConfig; +}; + // Table cell values are typically primitives export type TableCellValue = string | number | boolean | null; @@ -1151,6 +1189,13 @@ export function determineDataType(schema: BlockIOSubSchema): DataType { return DataType.CREDENTIALS; } + if ( + "google_drive_picker_config" in schema || + ("format" in schema && schema.format === "google-drive-picker") + ) { + return DataType.GOOGLE_DRIVE_PICKER; + } + // enum == SELECT if ("enum" in schema) { return DataType.SELECT;