Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 163 additions & 4 deletions app/src/components/Workspace/GoogleDriveResourcePickerDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { GoogleDriveResourcePickerDialog } from './GoogleDriveResourcePickerDialog'
Expand All @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
listChildren: vi.fn(),
listRoots: vi.fn(),
searchResources: vi.fn(),
resolvePaths: vi.fn(),
}))

vi.mock('./googleDriveBrowser', async () => {
Expand All @@ -23,8 +24,14 @@ vi.mock('./googleDriveBrowser', async () => {
}
})

vi.mock('./googleDrivePaths', () => ({
resolveGoogleDriveResourcePaths: mocks.resolvePaths,
}))

describe('GoogleDriveResourcePickerDialog', () => {
beforeEach(() => {
mocks.resolvePaths.mockReset()
mocks.resolvePaths.mockResolvedValue(undefined)
mocks.listRoots.mockReset()
mocks.listRoots.mockResolvedValue([
{ id: 'my-drive-root-id', name: 'My Drive' },
Expand Down Expand Up @@ -118,9 +125,7 @@ describe('GoogleDriveResourcePickerDialog', () => {
it('keeps an actionable error open and retries root listing', async () => {
mocks.listRoots
.mockRejectedValueOnce(new Error('Drive API disabled'))
.mockResolvedValueOnce([
{ id: 'my-drive-root-id', name: 'My Drive' },
])
.mockResolvedValueOnce([{ id: 'my-drive-root-id', name: 'My Drive' }])
render(
<GoogleDriveResourcePickerDialog
accessToken="token"
Expand Down Expand Up @@ -276,3 +281,157 @@ describe('GoogleDriveResourcePickerDialog', () => {
trigger.remove()
})
})

/** Starts a search only after root discovery has completed. */
async function searchNotebooks() {
await screen.findByRole('button', { name: 'Open My Drive' })
fireEvent.change(screen.getByRole('searchbox'), {
target: { value: 'Notebooks' },
})
fireEvent.click(screen.getByRole('button', { name: 'Search' }))
}

it('shows duplicate names immediately and progressively adds accessible full paths', async () => {
mocks.listRoots.mockResolvedValue([{ id: 'my-root', name: 'My Drive' }])
mocks.searchResources.mockResolvedValue([
{
id: 'a',
name: 'Notebooks',
mimeType: 'application/vnd.google-apps.folder',
},
{
id: 'b',
name: 'Notebooks',
mimeType: 'application/vnd.google-apps.folder',
},
])
let publish!: (id: string, path: { label: string; complete: boolean }) => void
let finish!: () => void
mocks.resolvePaths.mockImplementation((_token, _matches, _roots, onPath) => {
publish = onPath
return new Promise<void>((resolve) => {
finish = resolve
})
})
render(
<GoogleDriveResourcePickerDialog
accessToken="token"
mode="folder"
onCancel={vi.fn()}
onSelect={vi.fn()}
/>
)
await searchNotebooks()
expect(
await screen.findAllByRole('button', { name: 'Open folder Notebooks' })
).toHaveLength(2)
expect(screen.getAllByText('Loading path…')).toHaveLength(2)
await act(async () => {
publish('a', { label: 'My Drive / Work / Notebooks', complete: true })
publish('b', { label: 'Engineering / Team / Notebooks', complete: true })
finish()
})
const rows = screen.getAllByRole('button', { name: 'Open folder Notebooks' })
expect(
document.getElementById(rows[0].getAttribute('aria-describedby')!)
?.textContent
).toBe('My Drive / Work / Notebooks')
expect(screen.getByTitle('Engineering / Team / Notebooks')).toBeTruthy()
})

it.each(['navigation', 'new search', 'credentials', 'unmount'])(
'aborts paths and ignores late callbacks after %s',
async (action) => {
mocks.listRoots.mockResolvedValue([{ id: 'my-root', name: 'My Drive' }])
mocks.listChildren.mockResolvedValue([])
mocks.searchResources.mockResolvedValue([
{
id: 'a',
name: 'Notebooks',
mimeType: 'application/vnd.google-apps.folder',
},
])
const pending: Array<{
publish: (id: string, path: { label: string; complete: boolean }) => void
signal: AbortSignal
finish: () => void
}> = []
mocks.resolvePaths.mockImplementation(
(_token, _matches, _roots, publish, signal) =>
new Promise<void>((finish) => pending.push({ publish, signal, finish }))
)
const props = {
accessToken: 'token',
mode: 'folder' as const,
onCancel: vi.fn(),
onSelect: vi.fn(),
}
const view = render(<GoogleDriveResourcePickerDialog {...props} />)
await searchNotebooks()
await screen.findByRole('button', { name: 'Open folder Notebooks' })
const first = pending[0]
if (action === 'navigation')
fireEvent.click(
screen.getByRole('button', { name: 'Open folder Notebooks' })
)
if (action === 'new search') {
fireEvent.change(screen.getByRole('searchbox'), {
target: { value: 'Other' },
})
fireEvent.click(screen.getByRole('button', { name: 'Search' }))
await waitFor(() => expect(pending).toHaveLength(2))
}
if (action === 'credentials')
view.rerender(
<GoogleDriveResourcePickerDialog {...props} accessToken="other-token" />
)
if (action === 'unmount') view.unmount()
expect(first.signal.aborted).toBe(true)
await act(async () => {
first.publish('a', { label: 'Stale path', complete: true })
first.finish()
pending[1]?.finish()
})
expect(screen.queryByText('Stale path')).toBeNull()
}
)

it('keeps file selection usable while a partial path is published', async () => {
mocks.listRoots.mockResolvedValue([{ id: 'my-root', name: 'My Drive' }])
mocks.searchResources.mockResolvedValue([
{
id: 'file-id',
name: 'notes.json',
mimeType: 'application/json',
resourceKey: 'key',
},
])
mocks.resolvePaths.mockImplementation(
async (_token, _matches, _roots, publish) => {
publish('file-id', {
label: '… / notes.json (partial path)',
complete: false,
})
}
)
const onSelect = vi.fn()
render(
<GoogleDriveResourcePickerDialog
accessToken="token"
mode="file"
onCancel={vi.fn()}
onSelect={onSelect}
/>
)
await searchNotebooks()
fireEvent.click(
await screen.findByRole('button', { name: 'Select file notes.json' })
)
expect(screen.getByText('… / notes.json (partial path)')).toBeTruthy()
fireEvent.click(
screen.getByRole('button', { name: 'Select file', exact: true })
)
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ id: 'file-id', resourceKey: 'key' })
)
})
50 changes: 46 additions & 4 deletions app/src/components/Workspace/GoogleDriveResourcePickerDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import type {
GoogleDriveResource,
} from './googleDriveBrowser'

import {
resolveGoogleDriveResourcePaths,
type GoogleDriveResourcePath,
} from './googleDrivePaths'

export type GoogleDrivePickerMode = 'file' | 'folder'

export type PickedGoogleDriveResource = {
Expand Down Expand Up @@ -54,6 +59,10 @@ export function GoogleDriveResourcePickerDialog({
}: GoogleDriveResourcePickerDialogProps) {
const dialogRef = useRef<HTMLElement | null>(null)
const requestIdRef = useRef(0)
const pathsAbortRef = useRef<AbortController | null>(null)
const [paths, setPaths] = useState<Record<string, GoogleDriveResourcePath>>(
{}
)
const [roots, setRoots] = useState<GoogleDriveLocation[]>([])
const [breadcrumbs, setBreadcrumbs] = useState<GoogleDriveLocation[]>([])
const [resources, setResources] = useState<GoogleDriveResource[]>([])
Expand All @@ -69,6 +78,8 @@ export function GoogleDriveResourcePickerDialog({

const loadRoots = useCallback(async () => {
const requestId = ++requestIdRef.current
pathsAbortRef.current?.abort()
setPaths({})
setLoading(true)
setErrorMessage('')
setBreadcrumbs([])
Expand Down Expand Up @@ -107,6 +118,8 @@ export function GoogleDriveResourcePickerDialog({
nextBreadcrumbs: GoogleDriveLocation[]
) => {
const requestId = ++requestIdRef.current
pathsAbortRef.current?.abort()
setPaths({})
setLoading(true)
setErrorMessage('')
setBreadcrumbs(nextBreadcrumbs)
Expand Down Expand Up @@ -155,6 +168,8 @@ export function GoogleDriveResourcePickerDialog({
return
}
const requestId = ++requestIdRef.current
pathsAbortRef.current?.abort()
setPaths({})
setLoading(true)
setErrorMessage('')
setActiveSearch(query)
Expand All @@ -169,6 +184,20 @@ export function GoogleDriveResourcePickerDialog({
)
if (requestId === requestIdRef.current) {
setResources(matches)
setLoading(false)
const controller = new AbortController()
pathsAbortRef.current = controller
await resolveGoogleDriveResourcePaths(
accessToken,
matches,
roots,
(id, path) => {
if (requestId === requestIdRef.current) {
setPaths((previous) => ({ ...previous, [id]: path }))
}
},
controller.signal
)
}
} catch (error) {
if (requestId !== requestIdRef.current) {
Expand All @@ -192,13 +221,14 @@ export function GoogleDriveResourcePickerDialog({
}
}
},
[accessToken, loadRoots, mode]
[accessToken, loadRoots, mode, roots]
)

useEffect(() => {
void loadRoots()
return () => {
requestIdRef.current += 1
pathsAbortRef.current?.abort()
}
}, [loadRoots])

Expand Down Expand Up @@ -445,10 +475,12 @@ export function GoogleDriveResourcePickerDialog({
</p>
) : (
<div id="google-drive-resource-picker-items" className="space-y-2">
{visibleResources.map((resource) => {
{visibleResources.map((resource, index) => {
const isFolder =
resource.mimeType === GOOGLE_DRIVE_FOLDER_MIME_TYPE
const selected = selectedFile?.id === resource.id
const pathLabel = paths[resource.id]?.label ?? 'Loading path…'
const pathId = `google-drive-resource-picker-path-${index}`
return (
<button
key={resource.id}
Expand All @@ -459,6 +491,7 @@ export function GoogleDriveResourcePickerDialog({
: `Select file ${resource.name}`
}
aria-pressed={isFolder ? undefined : selected}
aria-describedby={activeSearch ? pathId : undefined}
className={`flex w-full items-center gap-3 rounded-nb-sm border px-3 py-3 text-left focus:outline-none focus:ring-2 focus:ring-nb-accent-soft ${
selected
? 'border-nb-accent bg-nb-accent-muted'
Expand All @@ -476,8 +509,17 @@ export function GoogleDriveResourcePickerDialog({
}}
>
{isFolder ? <FolderIcon /> : <FileIcon />}
<span className="min-w-0 flex-1 truncate">
{resource.name}
<span className="min-w-0 flex-1">
<span className="block truncate">{resource.name}</span>
{activeSearch ? (
<span
id={pathId}
title={pathLabel}
className="mt-1 block break-words text-xs text-nb-text-muted"
>
{pathLabel}
</span>
) : null}
</span>
<span className="text-xs text-nb-text-muted">
{isFolder ? 'Folder' : 'File'}
Expand Down
Loading
Loading