From 947d9ce4598d6577160eb4e2282cb4eeaf5ecd7c Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Sat, 25 Jul 2026 14:30:24 -0400 Subject: [PATCH 1/2] fix: make Drive folder picker navigable with breadcrumb path ViewId.FOLDERS renders every folder in one flat list with no hierarchy. Switch to a DOCS view rooted at My Drive (setParent "root") with folder- only mimeTypes, which gives a navigable tree + breadcrumb path so the user can see where a folder lives and drill down, while still only seeing and selecting folders. Co-Authored-By: Claude Opus 4.8 --- __tests__/google-picker.test.js | 3 ++- lib/google-picker.js | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/__tests__/google-picker.test.js b/__tests__/google-picker.test.js index f96ad0c..0a70f9b 100644 --- a/__tests__/google-picker.test.js +++ b/__tests__/google-picker.test.js @@ -21,9 +21,10 @@ describe("pickDriveFolder", () => { const fakePicker = { setVisible: jest.fn() }; const fakeGoogle = { picker: { - ViewId: { FOLDERS: "folders" }, + ViewId: { FOLDERS: "folders", DOCS: "docs" }, Action: { PICKED: "picked", CANCEL: "cancel" }, DocsView: jest.fn().mockImplementation(() => ({ + setParent: jest.fn().mockReturnThis(), setMimeTypes: jest.fn().mockReturnThis(), setSelectFolderEnabled: jest.fn().mockReturnThis(), setIncludeFolders: jest.fn().mockReturnThis(), diff --git a/lib/google-picker.js b/lib/google-picker.js index b5ed252..8848341 100644 --- a/lib/google-picker.js +++ b/lib/google-picker.js @@ -101,10 +101,16 @@ export async function pickDriveFolder({ accessToken, apiKey, appId }) { return new Promise((resolve, reject) => { try { - const view = new google.picker.DocsView(google.picker.ViewId.FOLDERS) + // A DOCS view rooted at My Drive (setParent "root") gives a navigable + // folder tree with a breadcrumb path -- unlike ViewId.FOLDERS, which + // renders every folder in one flat list with no hierarchy. Restricting + // mimeTypes to folders keeps files out, so the user only sees and picks + // folders while still drilling down through the tree. + const view = new google.picker.DocsView(google.picker.ViewId.DOCS) + .setParent("root") .setMimeTypes("application/vnd.google-apps.folder") - .setSelectFolderEnabled(true) - .setIncludeFolders(true); + .setIncludeFolders(true) + .setSelectFolderEnabled(true); const picker = new google.picker.PickerBuilder() .addView(view) From e7243bd1e5efe3502c272eb6d80c08157e019d9d Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Sat, 25 Jul 2026 14:41:46 -0400 Subject: [PATCH 2/2] feat: shared-drive support + picker button contrast for gdrive folders - Picker: add a Shared Drives view (setEnableDrives) alongside the My Drive tree and enable the SUPPORT_DRIVES feature, so folders in shared/team drives are browsable and selectable. - gdrive.ts: pass supportsAllDrives=true on every Drive API call, plus includeItemsFromAllDrives=true on the list/query calls -- without these, a shared-drive folder is invisible to queries and unwritable on the server. Harmless no-ops for My Drive. - new.js: give the "Choose Drive folder" button colorPalette brandTeal so it reads clearly against the background instead of near-invisible gray. Tests updated (picker mock, strict-URL unit assertions). Verified: build clean, 32 unit/front-end + 14 emulator tests green. Co-Authored-By: Claude Opus 4.8 --- __tests__/google-picker.test.js | 3 +++ functions/src/__tests__/providers-gdrive.test.js | 8 ++++---- functions/src/providers/gdrive.ts | 16 ++++++++++++---- lib/google-picker.js | 14 ++++++++++++-- pages/admin/new.js | 1 + 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/__tests__/google-picker.test.js b/__tests__/google-picker.test.js index 0a70f9b..c952c1f 100644 --- a/__tests__/google-picker.test.js +++ b/__tests__/google-picker.test.js @@ -23,14 +23,17 @@ describe("pickDriveFolder", () => { picker: { ViewId: { FOLDERS: "folders", DOCS: "docs" }, Action: { PICKED: "picked", CANCEL: "cancel" }, + Feature: { SUPPORT_DRIVES: "supportDrives" }, DocsView: jest.fn().mockImplementation(() => ({ setParent: jest.fn().mockReturnThis(), setMimeTypes: jest.fn().mockReturnThis(), setSelectFolderEnabled: jest.fn().mockReturnThis(), setIncludeFolders: jest.fn().mockReturnThis(), + setEnableDrives: jest.fn().mockReturnThis(), })), PickerBuilder: jest.fn().mockImplementation(() => ({ addView: jest.fn().mockReturnThis(), + enableFeature: jest.fn().mockReturnThis(), setOAuthToken: jest.fn().mockReturnThis(), setDeveloperKey: jest.fn().mockReturnThis(), setAppId: jest.fn().mockReturnThis(), diff --git a/functions/src/__tests__/providers-gdrive.test.js b/functions/src/__tests__/providers-gdrive.test.js index 29f0ad2..59906ec 100644 --- a/functions/src/__tests__/providers-gdrive.test.js +++ b/functions/src/__tests__/providers-gdrive.test.js @@ -104,7 +104,7 @@ describe("1. writeSessionFile success", () => { expect(mockFetch).toHaveBeenCalledTimes(1); const { url, options } = callArgs(0); - expect(url).toBe(`${API_BASE}/upload/drive/v3/files?uploadType=multipart`); + expect(url).toBe(`${API_BASE}/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true`); expect(options.method).toBe("POST"); expect(header(options.headers, "Authorization")).toBe("Bearer test-token"); @@ -217,7 +217,7 @@ describe("2. writeSessionFile subfolder", () => { }); const uploadCall = callArgs(2); - expect(uploadCall.url).toBe(`${API_BASE}/upload/drive/v3/files?uploadType=multipart`); + expect(uploadCall.url).toBe(`${API_BASE}/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true`); const uploadBody = uploadCall.options.body.toString(); expect(uploadBody).toContain('"name":"file.csv"'); expect(uploadBody).toContain('"parents":["sub-folder-id"]'); @@ -510,7 +510,7 @@ describe("5. updateFile", () => { expect(mockFetch).toHaveBeenCalledTimes(1); const { url, options } = callArgs(0); - expect(url).toBe(`${API_BASE}/upload/drive/v3/files/gdrive-existing-1?uploadType=media`); + expect(url).toBe(`${API_BASE}/upload/drive/v3/files/gdrive-existing-1?uploadType=media&supportsAllDrives=true`); expect(options.method).toBe("PATCH"); expect(header(options.headers, "Authorization")).toBe("Bearer test-token"); expect(options.body).toBe("updated-data"); @@ -616,7 +616,7 @@ describe("7. downloadFile", () => { expect(mockFetch).toHaveBeenCalledTimes(1); const { url, options } = callArgs(0); - expect(url).toBe(`${API_BASE}/drive/v3/files/gdrive-file-9?alt=media`); + expect(url).toBe(`${API_BASE}/drive/v3/files/gdrive-file-9?alt=media&supportsAllDrives=true`); expect(options.method).toBe("GET"); expect(header(options.headers, "Authorization")).toBe("Bearer test-token"); diff --git a/functions/src/providers/gdrive.ts b/functions/src/providers/gdrive.ts index a863b75..6eee8ce 100644 --- a/functions/src/providers/gdrive.ts +++ b/functions/src/providers/gdrive.ts @@ -119,6 +119,10 @@ async function findFolder( const url = new URL(`${getApiBase()}/drive/v3/files`); const q = `name='${escapeQueryValue(name)}' and '${parentId}' in parents and mimeType='${FOLDER_MIME}' and trashed=false`; url.searchParams.set("q", q); + // Shared Drives require these on every list/query call, or folders that + // live in a shared drive are invisible to the query. + url.searchParams.set("supportsAllDrives", "true"); + url.searchParams.set("includeItemsFromAllDrives", "true"); const response = await fetch(url.toString(), { method: "GET", @@ -136,7 +140,7 @@ async function findFolder( } async function createFolder(auth: ResolvedAuth, name: string, parentId: string): Promise { - const response = await fetch(`${getApiBase()}/drive/v3/files`, { + const response = await fetch(`${getApiBase()}/drive/v3/files?supportsAllDrives=true`, { method: "POST", headers: { ...authHeaders(auth), @@ -252,7 +256,7 @@ export const gdriveProvider: StorageProvider = { meta.contentType ); - const response = await fetch(`${getApiBase()}/upload/drive/v3/files?uploadType=multipart`, { + const response = await fetch(`${getApiBase()}/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true`, { method: "POST", headers: { ...authHeaders(auth), @@ -288,7 +292,7 @@ export const gdriveProvider: StorageProvider = { meta: FileMeta ): Promise { const response = await fetch( - `${getApiBase()}/upload/drive/v3/files/${existingFileRef.id}?uploadType=media`, + `${getApiBase()}/upload/drive/v3/files/${existingFileRef.id}?uploadType=media&supportsAllDrives=true`, { method: "PATCH", headers: { @@ -337,6 +341,10 @@ export const gdriveProvider: StorageProvider = { url.searchParams.set("q", q); url.searchParams.set("fields", "nextPageToken,files(id,name,mimeType)"); url.searchParams.set("pageSize", "1000"); + // Shared Drives require these on every list/query call, or folders + // that live in a shared drive are invisible to the query. + url.searchParams.set("supportsAllDrives", "true"); + url.searchParams.set("includeItemsFromAllDrives", "true"); if (pageToken) { url.searchParams.set("pageToken", pageToken); } @@ -378,7 +386,7 @@ export const gdriveProvider: StorageProvider = { _container: ContainerRef, fileRef: FileRef ): Promise { - const response = await fetch(`${getApiBase()}/drive/v3/files/${fileRef.id}?alt=media`, { + const response = await fetch(`${getApiBase()}/drive/v3/files/${fileRef.id}?alt=media&supportsAllDrives=true`, { method: "GET", headers: authHeaders(auth), }); diff --git a/lib/google-picker.js b/lib/google-picker.js index 8848341..8299fc4 100644 --- a/lib/google-picker.js +++ b/lib/google-picker.js @@ -106,14 +106,24 @@ export async function pickDriveFolder({ accessToken, apiKey, appId }) { // renders every folder in one flat list with no hierarchy. Restricting // mimeTypes to folders keeps files out, so the user only sees and picks // folders while still drilling down through the tree. - const view = new google.picker.DocsView(google.picker.ViewId.DOCS) + const myDriveView = new google.picker.DocsView(google.picker.ViewId.DOCS) .setParent("root") .setMimeTypes("application/vnd.google-apps.folder") .setIncludeFolders(true) .setSelectFolderEnabled(true); + // A second DOCS view (setEnableDrives) surfaces Shared Drives as their + // own navigable, folder-only tree alongside My Drive above. + const sharedDrivesView = new google.picker.DocsView(google.picker.ViewId.DOCS) + .setEnableDrives(true) + .setMimeTypes("application/vnd.google-apps.folder") + .setIncludeFolders(true) + .setSelectFolderEnabled(true); + const picker = new google.picker.PickerBuilder() - .addView(view) + .addView(myDriveView) + .addView(sharedDrivesView) + .enableFeature(google.picker.Feature.SUPPORT_DRIVES) .setOAuthToken(accessToken) .setDeveloperKey(apiKey) .setAppId(appId) diff --git a/pages/admin/new.js b/pages/admin/new.js index 5caf100..c82ebd4 100644 --- a/pages/admin/new.js +++ b/pages/admin/new.js @@ -334,6 +334,7 @@ function NewExperimentForm() {