Skip to content

Commit 02f8a69

Browse files
ntindle0ubbemajdyzclaude
authored
feat(platform): add Google Drive Picker field type for enhanced file selection (#11311)
### 🏗️ 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: <!-- Put your test plan here: --> - [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 <[email protected]> Co-authored-by: Zamil Majdy <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent e983d5c commit 02f8a69

File tree

9 files changed

+1477
-275
lines changed

9 files changed

+1477
-275
lines changed
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import asyncio
2+
import mimetypes
3+
import uuid
4+
from pathlib import Path
5+
from typing import Any, Literal, Optional
6+
7+
from pydantic import BaseModel, ConfigDict, Field
8+
9+
from backend.data.model import SchemaField
10+
from backend.util.file import get_exec_file_path
11+
from backend.util.request import Requests
12+
from backend.util.type import MediaFileType
13+
from backend.util.virus_scanner import scan_content_safe
14+
15+
AttachmentView = Literal[
16+
"DOCS",
17+
"DOCUMENTS",
18+
"SPREADSHEETS",
19+
"PRESENTATIONS",
20+
"DOCS_IMAGES",
21+
"FOLDERS",
22+
]
23+
ATTACHMENT_VIEWS: tuple[AttachmentView, ...] = (
24+
"DOCS",
25+
"DOCUMENTS",
26+
"SPREADSHEETS",
27+
"PRESENTATIONS",
28+
"DOCS_IMAGES",
29+
"FOLDERS",
30+
)
31+
32+
33+
class GoogleDriveFile(BaseModel):
34+
"""Represents a single file/folder picked from Google Drive"""
35+
36+
model_config = ConfigDict(populate_by_name=True)
37+
38+
id: str = Field(description="Google Drive file/folder ID")
39+
name: Optional[str] = Field(None, description="File/folder name")
40+
mime_type: Optional[str] = Field(
41+
None,
42+
alias="mimeType",
43+
description="MIME type (e.g., application/vnd.google-apps.document)",
44+
)
45+
url: Optional[str] = Field(None, description="URL to open the file")
46+
icon_url: Optional[str] = Field(None, alias="iconUrl", description="Icon URL")
47+
is_folder: Optional[bool] = Field(
48+
None, alias="isFolder", description="Whether this is a folder"
49+
)
50+
51+
52+
def GoogleDrivePickerField(
53+
multiselect: bool = False,
54+
allow_folder_selection: bool = False,
55+
allowed_views: Optional[list[AttachmentView]] = None,
56+
allowed_mime_types: Optional[list[str]] = None,
57+
scopes: Optional[list[str]] = None,
58+
title: Optional[str] = None,
59+
description: Optional[str] = None,
60+
placeholder: Optional[str] = None,
61+
**kwargs,
62+
) -> Any:
63+
"""
64+
Creates a Google Drive Picker input field.
65+
66+
Args:
67+
multiselect: Allow selecting multiple files/folders (default: False)
68+
allow_folder_selection: Allow selecting folders (default: False)
69+
allowed_views: List of view types to show in picker (default: ["DOCS"])
70+
allowed_mime_types: Filter by MIME types (e.g., ["application/pdf"])
71+
title: Field title shown in UI
72+
description: Field description/help text
73+
placeholder: Placeholder text for the button
74+
**kwargs: Additional SchemaField arguments (advanced, hidden, etc.)
75+
76+
Returns:
77+
Field definition that produces:
78+
- Single GoogleDriveFile when multiselect=False
79+
- list[GoogleDriveFile] when multiselect=True
80+
81+
Example:
82+
>>> class MyBlock(Block):
83+
... class Input(BlockSchema):
84+
... document: GoogleDriveFile = GoogleDrivePickerField(
85+
... title="Select Document",
86+
... allowed_views=["DOCUMENTS"],
87+
... )
88+
...
89+
... files: list[GoogleDriveFile] = GoogleDrivePickerField(
90+
... title="Select Multiple Files",
91+
... multiselect=True,
92+
... allow_folder_selection=True,
93+
... )
94+
"""
95+
# Build configuration that will be sent to frontend
96+
picker_config = {
97+
"multiselect": multiselect,
98+
"allow_folder_selection": allow_folder_selection,
99+
"allowed_views": list(allowed_views) if allowed_views else ["DOCS"],
100+
}
101+
102+
# Add optional configurations
103+
if allowed_mime_types:
104+
picker_config["allowed_mime_types"] = list(allowed_mime_types)
105+
106+
# Determine required scopes based on config
107+
base_scopes = scopes if scopes is not None else []
108+
picker_scopes: set[str] = set(base_scopes)
109+
if allow_folder_selection:
110+
picker_scopes.add("https://www.googleapis.com/auth/drive")
111+
else:
112+
# Use drive.file for minimal scope - only access files selected by user in picker
113+
picker_scopes.add("https://www.googleapis.com/auth/drive.file")
114+
115+
views = set(allowed_views or [])
116+
if "SPREADSHEETS" in views:
117+
picker_scopes.add("https://www.googleapis.com/auth/spreadsheets.readonly")
118+
if "DOCUMENTS" in views or "DOCS" in views:
119+
picker_scopes.add("https://www.googleapis.com/auth/documents.readonly")
120+
121+
picker_config["scopes"] = sorted(picker_scopes)
122+
123+
# Set appropriate default value
124+
default_value = [] if multiselect else None
125+
126+
# Use SchemaField to handle format properly
127+
return SchemaField(
128+
default=default_value,
129+
title=title,
130+
description=description,
131+
placeholder=placeholder or "Choose from Google Drive",
132+
format="google-drive-picker",
133+
advanced=False,
134+
json_schema_extra={
135+
"google_drive_picker_config": picker_config,
136+
**kwargs,
137+
},
138+
)
139+
140+
141+
DRIVE_API_URL = "https://www.googleapis.com/drive/v3/files"
142+
_requests = Requests(trusted_origins=["https://www.googleapis.com"])
143+
144+
145+
def GoogleDriveAttachmentField(
146+
*,
147+
title: str,
148+
description: str | None = None,
149+
placeholder: str | None = None,
150+
multiselect: bool = True,
151+
allowed_mime_types: list[str] | None = None,
152+
**extra: Any,
153+
) -> Any:
154+
return GoogleDrivePickerField(
155+
multiselect=multiselect,
156+
allowed_views=list(ATTACHMENT_VIEWS),
157+
allowed_mime_types=allowed_mime_types,
158+
title=title,
159+
description=description,
160+
placeholder=placeholder or "Choose files from Google Drive",
161+
**extra,
162+
)
163+
164+
165+
async def drive_file_to_media_file(
166+
drive_file: GoogleDriveFile, *, graph_exec_id: str, access_token: str
167+
) -> MediaFileType:
168+
if drive_file.is_folder:
169+
raise ValueError("Google Drive selection must be a file.")
170+
if not access_token:
171+
raise ValueError("Google Drive access token is required for file download.")
172+
173+
url = f"{DRIVE_API_URL}/{drive_file.id}?alt=media"
174+
response = await _requests.get(
175+
url, headers={"Authorization": f"Bearer {access_token}"}
176+
)
177+
178+
mime_type = drive_file.mime_type or response.headers.get(
179+
"content-type", "application/octet-stream"
180+
)
181+
182+
MAX_FILE_SIZE = 100 * 1024 * 1024
183+
if len(response.content) > MAX_FILE_SIZE:
184+
raise ValueError(
185+
f"File too large: {len(response.content)} bytes > {MAX_FILE_SIZE} bytes"
186+
)
187+
188+
base_path = Path(get_exec_file_path(graph_exec_id, ""))
189+
base_path.mkdir(parents=True, exist_ok=True)
190+
191+
extension = mimetypes.guess_extension(mime_type, strict=False) or ".bin"
192+
filename = f"{uuid.uuid4()}{extension}"
193+
target_path = base_path / filename
194+
195+
await scan_content_safe(response.content, filename=filename)
196+
await asyncio.to_thread(target_path.write_bytes, response.content)
197+
198+
return MediaFileType(str(target_path.relative_to(base_path)))

0 commit comments

Comments
 (0)