diff --git a/__tests__/google-picker.test.js b/__tests__/google-picker.test.js index f96ad0c..c952c1f 100644 --- a/__tests__/google-picker.test.js +++ b/__tests__/google-picker.test.js @@ -21,15 +21,19 @@ describe("pickDriveFolder", () => { const fakePicker = { setVisible: jest.fn() }; const fakeGoogle = { picker: { - ViewId: { FOLDERS: "folders" }, + 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 b5ed252..8299fc4 100644 --- a/lib/google-picker.js +++ b/lib/google-picker.js @@ -101,13 +101,29 @@ 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 myDriveView = new google.picker.DocsView(google.picker.ViewId.DOCS) + .setParent("root") .setMimeTypes("application/vnd.google-apps.folder") - .setSelectFolderEnabled(true) - .setIncludeFolders(true); + .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() {