mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio): deep-link folders and files in the storage explorer (#50413)
| | PR | Base | Branch | | --- | --- | --- | --- | | 1 | #50476 | `master` | pre-existing correctness fixes | | 2 | **this PR** | `fix/storage-explorer-listing-and-scroll` | `?path`/`?preview` deep-linking | | 3 | #50478 | `feat/storage-nav-improvement` | end-to-end deep-link test | | 4 | #50480 | `test/storage-deep-link-e2e` | copy path / copy link row actions | ## What is the current behavior? The file explorer doesn't keep track of folder navigation. Files and folders paths aren't shareable ## What is the new behavior? With this PR: - nav state is stored via params - "path" to store folder path (if nested folder paths) - "preview" to store the selected filename - back/forward nav history - file url opens correct folder/file [https://github.com/user-attachments/assets/](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[0cfb7fcc-2c6e](https://github.com/user-attachments/assets/0cfb7fcc-2c6e-4f5a-950d-060c8eb2027b)[528d5c1d-a1b9](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[-](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[4f5a-950d](https://github.com/user-attachments/assets/0cfb7fcc-2c6e-4f5a-950d-060c8eb2027b)[4061-9b67](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[-](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[060c8eb2027b](https://github.com/user-attachments/assets/0cfb7fcc-2c6e-4f5a-950d-060c8eb2027b)[a41dd98716e0](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0) ## Steps to review - Open bucket in Storage File Explorer - navigate between files and folders and notice url params change - reload page, it should reopen where you left off - hitting back/forward on the browser history should follow file/folder navigation history --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com>
This commit is contained in:
13 files changed
+1016
-233
No files matched your search
+17
-8
@@ -11,11 +11,15 @@ const {
|
||||
mockUseStorageExplorerStateSnapshot,
|
||||
mockUseAsyncCheckPermissions,
|
||||
mockUseStoragePreference,
|
||||
mockGoUpOneLevel,
|
||||
mockNavigateToPath,
|
||||
} = vi.hoisted(() => ({
|
||||
mockTrack: vi.fn(),
|
||||
mockUseStorageExplorerStateSnapshot: vi.fn(),
|
||||
mockUseAsyncCheckPermissions: vi.fn(),
|
||||
mockUseStoragePreference: vi.fn(),
|
||||
mockGoUpOneLevel: vi.fn(),
|
||||
mockNavigateToPath: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/telemetry/track', () => ({ useTrack: () => mockTrack }))
|
||||
@@ -26,6 +30,13 @@ vi.mock('@/hooks/misc/useCheckPermissions', () => ({
|
||||
useAsyncCheckPermissions: () => mockUseAsyncCheckPermissions(),
|
||||
}))
|
||||
|
||||
vi.mock('./StorageExplorerNavigation', () => ({
|
||||
useStorageExplorerNavigation: () => ({
|
||||
goUpOneLevel: mockGoUpOneLevel,
|
||||
navigateToPath: mockNavigateToPath,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./useStoragePreference', () => ({
|
||||
useStoragePreference: (...args: any[]) => mockUseStoragePreference(...args),
|
||||
}))
|
||||
@@ -77,6 +88,8 @@ describe('FileExplorerHeader', () => {
|
||||
mockUseStorageExplorerStateSnapshot.mockReset()
|
||||
mockUseAsyncCheckPermissions.mockReset()
|
||||
mockUseStoragePreference.mockReset()
|
||||
mockGoUpOneLevel.mockReset()
|
||||
mockNavigateToPath.mockReset()
|
||||
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(createSnapshot())
|
||||
mockUseStoragePreference.mockReturnValue(createPreference())
|
||||
@@ -166,9 +179,7 @@ describe('FileExplorerHeader', () => {
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Go up one level' }))
|
||||
|
||||
expect(snapshot.popColumn).toHaveBeenCalled()
|
||||
expect(snapshot.popOpenedFolders).toHaveBeenCalled()
|
||||
expect(snapshot.setSelectedFilePreview).toHaveBeenCalledWith(undefined)
|
||||
expect(mockGoUpOneLevel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens path edit mode from Navigate and tracks the click', async () => {
|
||||
@@ -189,7 +200,7 @@ describe('FileExplorerHeader', () => {
|
||||
expect(screen.getByRole('button', { name: 'Navigate' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits a path, tracks the submission, and calls the existing path navigation flow', async () => {
|
||||
it('submits a path, tracks the submission, and navigates to it', async () => {
|
||||
const snapshot = createSnapshot()
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
|
||||
|
||||
@@ -209,7 +220,7 @@ describe('FileExplorerHeader', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Navigate' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(snapshot.fetchFoldersByPath).toHaveBeenCalledWith({ paths: ['archive', '2025'] })
|
||||
expect(mockNavigateToPath).toHaveBeenCalledWith(['archive', '2025'])
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(mockTrack).toHaveBeenCalledWith('storage_explorer_navigate_submitted')
|
||||
@@ -237,11 +248,9 @@ describe('FileExplorerHeader', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Navigate' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(snapshot.popColumnAtIndex).toHaveBeenCalledWith(0)
|
||||
expect(mockNavigateToPath).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
expect(snapshot.clearOpenedFolders).toHaveBeenCalled()
|
||||
expect(snapshot.setSelectedFilePreview).toHaveBeenCalledWith(undefined)
|
||||
expect(mockTrack).toHaveBeenCalledWith('storage_explorer_navigate_clicked')
|
||||
expect(mockTrack).not.toHaveBeenCalledWith('storage_explorer_navigate_submitted')
|
||||
})
|
||||
|
||||
@@ -44,6 +44,7 @@ import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
|
||||
import { STORAGE_SORT_BY, STORAGE_SORT_BY_ORDER, STORAGE_VIEWS } from '../Storage.constants'
|
||||
import { pageChromeRowClassName } from './storageExplorerChrome'
|
||||
import { useStorageExplorerNavigation } from './StorageExplorerNavigation'
|
||||
import { useFileExplorerHeaderShortcuts } from './useFileExplorerHeaderShortcuts'
|
||||
import { useStoragePreference } from './useStoragePreference'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
@@ -143,7 +144,6 @@ export const FileExplorerHeader = ({
|
||||
const track = useTrack()
|
||||
|
||||
const [pathString, setPathString] = useState('')
|
||||
const [loading, setLoading] = useState({ isLoading: false, message: '' })
|
||||
|
||||
const [isPathDialogOpen, setIsPathDialogOpen] = useState(false)
|
||||
|
||||
@@ -154,17 +154,13 @@ export const FileExplorerHeader = ({
|
||||
const {
|
||||
projectRef,
|
||||
columns,
|
||||
popColumn,
|
||||
popColumnAtIndex,
|
||||
popOpenedFolders,
|
||||
fetchFoldersByPath,
|
||||
refetchAllOpenedFolders,
|
||||
refreshAll,
|
||||
isRefreshing,
|
||||
addNewFolderPlaceholder,
|
||||
clearOpenedFolders,
|
||||
setSelectedFilePreview,
|
||||
} = useStorageExplorerStateSnapshot()
|
||||
const { goUpOneLevel, navigateToPath } = useStorageExplorerNavigation()
|
||||
const {
|
||||
view,
|
||||
setView,
|
||||
@@ -216,9 +212,7 @@ export const FileExplorerHeader = ({
|
||||
}, [breadcrumbs])
|
||||
|
||||
const onSelectBack = () => {
|
||||
popColumn()
|
||||
popOpenedFolders()
|
||||
setSelectedFilePreview(undefined)
|
||||
goUpOneLevel()
|
||||
}
|
||||
|
||||
const onSelectUpload = () => {
|
||||
@@ -238,7 +232,7 @@ export const FileExplorerHeader = ({
|
||||
setPathString(event.target.value)
|
||||
}
|
||||
|
||||
const navigateByPathString = async (event?: SyntheticEvent) => {
|
||||
const navigateByPathString = (event?: SyntheticEvent) => {
|
||||
if (event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -246,26 +240,14 @@ export const FileExplorerHeader = ({
|
||||
|
||||
const paths = compact(pathString.split('/'))
|
||||
setIsPathDialogOpen(false)
|
||||
await onSetPathByString(paths)
|
||||
// Writing the URL is enough — the navigation provider rebuilds the columns from it.
|
||||
navigateToPath(paths)
|
||||
|
||||
if (paths.length > 0) {
|
||||
track('storage_explorer_navigate_submitted')
|
||||
}
|
||||
}
|
||||
|
||||
const onSetPathByString = async (paths: string[]) => {
|
||||
if (paths.length === 0) {
|
||||
popColumnAtIndex(0)
|
||||
clearOpenedFolders()
|
||||
setSelectedFilePreview(undefined)
|
||||
} else {
|
||||
const pathString = paths.join('/')
|
||||
setLoading({ isLoading: true, message: `Navigating to ${pathString}...` })
|
||||
await fetchFoldersByPath({ paths })
|
||||
setLoading({ isLoading: false, message: '' })
|
||||
}
|
||||
}
|
||||
|
||||
const cancelSetPathString = () => {
|
||||
setIsPathDialogOpen(false)
|
||||
}
|
||||
@@ -347,7 +329,7 @@ export const FileExplorerHeader = ({
|
||||
variant="outline"
|
||||
aria-label="Navigate"
|
||||
className="w-7 px-1"
|
||||
disabled={isPathDialogOpen || loading.isLoading}
|
||||
disabled={isPathDialogOpen}
|
||||
onClick={onOpenNavigate}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -39,6 +39,7 @@ import { StorageRowIcon } from '../StorageRowIcon'
|
||||
import { useFileExplorerContextMenu } from './FileExplorerRowContextMenu'
|
||||
import { FileExplorerRowEditing } from './FileExplorerRowEditing'
|
||||
import { copyPathToFolder } from './StorageExplorer.utils'
|
||||
import { useStorageExplorerNavigation } from './StorageExplorerNavigation'
|
||||
import { useCopyUrl } from './useCopyUrl'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { formatBytes } from '@/lib/helpers'
|
||||
@@ -65,20 +66,16 @@ export const FileExplorerRow = ({
|
||||
selectedBucket,
|
||||
selectedFilePreview,
|
||||
openedFolders,
|
||||
popColumnAtIndex,
|
||||
popOpenedFoldersAtIndex,
|
||||
clearSelectedItems,
|
||||
setSelectedFilePreview,
|
||||
setSelectedFileCustomExpiry,
|
||||
setSelectedItems,
|
||||
setSelectedItemsToDelete,
|
||||
downloadFile,
|
||||
setSelectedItemToRename,
|
||||
setSelectedItemsToMove,
|
||||
openFolder,
|
||||
downloadFolder,
|
||||
selectRangeItems,
|
||||
} = useStorageExplorerStateSnapshot()
|
||||
const { openFolderAtIndex, setPreviewedFile, clearPreviewedFile } = useStorageExplorerNavigation()
|
||||
const { onCopyUrl } = useCopyUrl()
|
||||
const ctx = useFileExplorerContextMenu()
|
||||
|
||||
@@ -90,13 +87,6 @@ export const FileExplorerRow = ({
|
||||
const isPreviewed = !isEmpty(selectedFilePreview) && isEqual(selectedFilePreview?.id, item.id)
|
||||
const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
|
||||
|
||||
const onSelectFile = async (columnIndex: number) => {
|
||||
popColumnAtIndex(columnIndex)
|
||||
popOpenedFoldersAtIndex(columnIndex - 1)
|
||||
setSelectedFilePreview(itemWithColumnIndex)
|
||||
clearSelectedItems()
|
||||
}
|
||||
|
||||
const onCheckItem = (isShiftKeyHeld: boolean) => {
|
||||
// Select a range if shift is held down
|
||||
if (isShiftKeyHeld && selectedItems.length !== 0) {
|
||||
@@ -110,7 +100,7 @@ export const FileExplorerRow = ({
|
||||
} else {
|
||||
setSelectedItems([...selectedItems, itemWithColumnIndex])
|
||||
}
|
||||
setSelectedFilePreview(undefined)
|
||||
clearPreviewedFile()
|
||||
}
|
||||
|
||||
const rowOptions =
|
||||
@@ -263,8 +253,8 @@ export const FileExplorerRow = ({
|
||||
event.preventDefault()
|
||||
if (item.status !== STORAGE_ROW_STATUS.LOADING && !isOpened && !isPreviewed) {
|
||||
item.type === STORAGE_ROW_TYPES.FOLDER
|
||||
? openFolder(columnIndex, item)
|
||||
: onSelectFile(columnIndex)
|
||||
? openFolderAtIndex(columnIndex, item)
|
||||
: setPreviewedFile(itemWithColumnIndex)
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { URL_EXPIRY_DURATION } from '../Storage.constants'
|
||||
import { StorageItem } from '../Storage.types'
|
||||
import { getPathAlongOpenedFolders } from './StorageExplorer.utils'
|
||||
import { useStorageExplorerNavigation } from './StorageExplorerNavigation'
|
||||
import { useCopyUrl } from './useCopyUrl'
|
||||
import { useFetchFileUrlQuery } from './useFetchFileUrlQuery'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
@@ -119,10 +120,10 @@ export const PreviewPane = () => {
|
||||
selectedBucket,
|
||||
selectedFilePreview: file,
|
||||
setSelectedItemsToDelete,
|
||||
setSelectedFilePreview,
|
||||
setSelectedFileCustomExpiry,
|
||||
downloadFile,
|
||||
} = useStorageExplorerStateSnapshot()
|
||||
const { clearPreviewedFile } = useStorageExplorerNavigation()
|
||||
const { onCopyUrl } = useCopyUrl()
|
||||
|
||||
const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
|
||||
@@ -141,8 +142,14 @@ export const PreviewPane = () => {
|
||||
style={{ width }}
|
||||
>
|
||||
{/* Preview Header */}
|
||||
<div className="flex w-full justify-end text-foreground-lighter transition-colors hover:text-foreground">
|
||||
<X className="cursor-pointer" size={14} onClick={() => setSelectedFilePreview(undefined)} />
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
variant="text"
|
||||
className="w-6 h-6 text-foreground-lighter hover:text-foreground"
|
||||
icon={<X size={14} />}
|
||||
aria-label="Close preview"
|
||||
onClick={clearPreviewedFile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preview Thumbnail*/}
|
||||
|
||||
@@ -1,198 +1,46 @@
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { useParams } from 'common'
|
||||
import { compact, get, isEmpty, uniqBy } from 'lodash'
|
||||
import { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useSelectedBucket } from '../FilesBuckets/useSelectedBucket'
|
||||
import { STORAGE_ROW_TYPES, STORAGE_VIEWS } from '../Storage.constants'
|
||||
import { ConfirmDeleteModal } from './ConfirmDeleteModal'
|
||||
import { CustomExpiryModal } from './CustomExpiryModal'
|
||||
import { FileExplorer } from './FileExplorer'
|
||||
import { FileExplorerHeader } from './FileExplorerHeader'
|
||||
import { FileExplorerHeaderSelection } from './FileExplorerHeaderSelection'
|
||||
import { MoveItemsModal } from './MoveItemsModal'
|
||||
import { PreviewPane } from './PreviewPane'
|
||||
import { useStorageExplorerShortcuts } from './useStorageExplorerShortcuts'
|
||||
import { useStoragePreference } from './useStoragePreference'
|
||||
import { StorageExplorerContent } from './StorageExplorerContent'
|
||||
import { StorageExplorerNavigationProvider } from './StorageExplorerNavigation'
|
||||
import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query'
|
||||
import type { Bucket } from '@/data/storage/buckets-query'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer'
|
||||
|
||||
export const StorageExplorer = () => {
|
||||
const { ref, bucketId } = useParams()
|
||||
const storageExplorerRef = useRef(null)
|
||||
const {
|
||||
projectRef,
|
||||
columns,
|
||||
selectedItems,
|
||||
openedFolders,
|
||||
selectedItemsToMove,
|
||||
selectedBucket,
|
||||
fetchFolderContents,
|
||||
fetchMoreFolderContents,
|
||||
fetchFoldersByPath,
|
||||
uploadFiles,
|
||||
moveFiles,
|
||||
popColumnAtIndex,
|
||||
popOpenedFoldersAtIndex,
|
||||
setSelectedItems,
|
||||
clearSelectedItems,
|
||||
setSelectedFilePreview,
|
||||
setSelectedItemsToMove,
|
||||
setIsSearching,
|
||||
} = useStorageExplorerStateSnapshot()
|
||||
const { view } = useStoragePreference(projectRef)
|
||||
const { selectedBucket } = useStorageExplorerStateSnapshot()
|
||||
|
||||
useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
|
||||
const { data: bucket, isLoading: isBucketQueryLoading } = useSelectedBucket()
|
||||
|
||||
// Detect when transitioning between buckets to avoid showing stale content from the previous bucket.
|
||||
// This happens because the bucket query and effects that update the store run after the first render.
|
||||
const isLoading = isBucketQueryLoading || (!!bucketId && bucketId !== selectedBucket.id)
|
||||
// Guards the gap where the query has settled but the store still holds another bucket.
|
||||
const isBucketReady = !isBucketQueryLoading && !!bucket && bucketId === selectedBucket.id
|
||||
|
||||
// This state exists outside of the header because FileExplorerColumn needs to listen to these as well
|
||||
// Things like showing results from a search filter is "temporary", hence we use react state to manage
|
||||
// Deliberately not in the URL, so a shared link points at a folder, not someone's filter.
|
||||
const [itemSearchString, setItemSearchString] = useState('')
|
||||
const debouncedSearchString = useDebounce(itemSearchString, 500)
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setIsSearching(false)
|
||||
setItemSearchString('')
|
||||
}, [setIsSearching])
|
||||
|
||||
useStorageExplorerShortcuts({ onClearSearch: handleClearSearch })
|
||||
|
||||
const fetchContents = useEffectEvent(async (bucket: Bucket) => {
|
||||
if (view === STORAGE_VIEWS.LIST) {
|
||||
const currentFolderIdx = openedFolders.length - 1
|
||||
const currentFolder = openedFolders[currentFolderIdx]
|
||||
|
||||
const folderId = !currentFolder ? bucket.id : currentFolder.id
|
||||
const folderName = !currentFolder ? bucket.name : currentFolder.name
|
||||
const index = !currentFolder ? -1 : currentFolderIdx
|
||||
|
||||
await fetchFolderContents({
|
||||
bucketId: bucket.id,
|
||||
folderId,
|
||||
folderName,
|
||||
index,
|
||||
searchString: debouncedSearchString,
|
||||
})
|
||||
} else if (view === STORAGE_VIEWS.COLUMNS) {
|
||||
if (openedFolders.length > 0) {
|
||||
const paths = openedFolders.map((folder) => folder.name)
|
||||
fetchFoldersByPath({
|
||||
paths,
|
||||
searchString: debouncedSearchString,
|
||||
showLoading: true,
|
||||
})
|
||||
} else {
|
||||
await fetchFolderContents({
|
||||
bucketId: bucket.id,
|
||||
folderId: bucket.id,
|
||||
folderName: bucket.name,
|
||||
index: -1,
|
||||
searchString: debouncedSearchString,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (bucket && projectRef) fetchContents(bucket)
|
||||
}, [bucket, projectRef, debouncedSearchString, selectedBucket.id])
|
||||
|
||||
/** Checkbox selection methods */
|
||||
/** [Joshen] We'll only support checkbox selection for files ONLY */
|
||||
|
||||
const onSelectAllItemsInColumn = (columnIndex: number) => {
|
||||
const columnFiles = columns[columnIndex].items
|
||||
.filter((item) => item.type === STORAGE_ROW_TYPES.FILE)
|
||||
.map((item) => {
|
||||
return { ...item, columnIndex }
|
||||
})
|
||||
const columnFilesId = compact(columnFiles.map((item) => item.id))
|
||||
const selectedItemsFromColumn = selectedItems.filter(
|
||||
(item) => item.id && columnFilesId.includes(item.id)
|
||||
)
|
||||
|
||||
if (selectedItemsFromColumn.length === columnFiles.length) {
|
||||
// Deselect all items from column
|
||||
const updatedSelectedItems = selectedItems.filter(
|
||||
(item) => item.id && !columnFilesId.includes(item.id)
|
||||
)
|
||||
setSelectedItems(updatedSelectedItems)
|
||||
} else {
|
||||
// Select all items from column
|
||||
const updatedSelectedItems = uniqBy(selectedItems.concat(columnFiles), 'id')
|
||||
setSelectedItems(updatedSelectedItems)
|
||||
}
|
||||
}
|
||||
|
||||
/** File manipulation methods */
|
||||
|
||||
const onFilesUpload = async (event: any, columnIndex: number = -1) => {
|
||||
event.persist()
|
||||
const items = event.target.files || event.dataTransfer.items
|
||||
const isDrop = !isEmpty(get(event, ['dataTransfer', 'items'], []))
|
||||
await uploadFiles({ files: items, columnIndex, isDrop })
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
const onMoveSelectedFiles = async (newPath: string) => {
|
||||
await moveFiles(newPath)
|
||||
}
|
||||
|
||||
/** Misc UI methods */
|
||||
const onSelectColumnEmptySpace = (columnIndex: number) => {
|
||||
popColumnAtIndex(columnIndex)
|
||||
popOpenedFoldersAtIndex(columnIndex - 1)
|
||||
setSelectedFilePreview(undefined)
|
||||
clearSelectedItems()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={storageExplorerRef} className="bg-studio flex h-full w-full flex-col">
|
||||
{selectedItems.length === 0 ? (
|
||||
<FileExplorerHeader
|
||||
<div className="bg-studio flex h-full w-full flex-col">
|
||||
{/* The skeleton swap is silent, and a live region must be mounted before it changes.
|
||||
`aria-live` rather than `role="status"`: that role is how toasts announce
|
||||
themselves here, and tests wait on it to tell when an upload has finished. */}
|
||||
<span aria-live="polite" aria-atomic="true" className="sr-only">
|
||||
{isBucketReady ? 'Bucket contents loaded' : 'Loading bucket contents'}
|
||||
</span>
|
||||
<StorageExplorerNavigationProvider
|
||||
isBucketReady={isBucketReady}
|
||||
searchString={debouncedSearchString}
|
||||
>
|
||||
<StorageExplorerContent
|
||||
itemSearchString={itemSearchString}
|
||||
setItemSearchString={setItemSearchString}
|
||||
onFilesUpload={onFilesUpload}
|
||||
isLoading={!isBucketReady}
|
||||
/>
|
||||
) : (
|
||||
<FileExplorerHeaderSelection />
|
||||
)}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<FileExplorer
|
||||
columns={columns}
|
||||
selectedItems={selectedItems}
|
||||
itemSearchString={itemSearchString}
|
||||
isLoading={isLoading}
|
||||
onFilesUpload={onFilesUpload}
|
||||
onSelectAllItemsInColumn={onSelectAllItemsInColumn}
|
||||
onSelectColumnEmptySpace={onSelectColumnEmptySpace}
|
||||
onColumnLoadMore={(index, column) =>
|
||||
fetchMoreFolderContents({ index, column, searchString: itemSearchString })
|
||||
}
|
||||
/>
|
||||
<PreviewPane />
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteModal />
|
||||
|
||||
<MoveItemsModal
|
||||
visible={selectedItemsToMove.length > 0}
|
||||
projectRef={projectRef}
|
||||
bucketId={selectedBucket.id}
|
||||
bucketName={selectedBucket.name}
|
||||
selectedItemsToMove={selectedItemsToMove}
|
||||
openedFolders={openedFolders}
|
||||
onSelectCancel={() => setSelectedItemsToMove([])}
|
||||
onSelectMove={onMoveSelectedFiles}
|
||||
/>
|
||||
|
||||
<CustomExpiryModal />
|
||||
</StorageExplorerNavigationProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+42
@@ -9,7 +9,9 @@ import type { StorageItem } from '@/components/interfaces/Storage/Storage.types'
|
||||
import {
|
||||
getPathAlongFoldersToIndex,
|
||||
getPathAlongOpenedFolders,
|
||||
parseStoragePath,
|
||||
sanitizeNameForDuplicateInColumn,
|
||||
serializeStoragePath,
|
||||
validateFolderName,
|
||||
} from '@/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils'
|
||||
|
||||
@@ -276,3 +278,43 @@ describe('sanitizeNameForDuplicateInColumn', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseStoragePath', () => {
|
||||
it('returns an empty array for an absent or empty param', () => {
|
||||
expect(parseStoragePath(null)).toEqual([])
|
||||
expect(parseStoragePath(undefined)).toEqual([])
|
||||
expect(parseStoragePath('')).toEqual([])
|
||||
})
|
||||
|
||||
it('splits a slash-joined path into segments', () => {
|
||||
expect(parseStoragePath('a/b/c')).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('tolerates leading, trailing and repeated slashes', () => {
|
||||
expect(parseStoragePath('/a//b/')).toEqual(['a', 'b'])
|
||||
expect(parseStoragePath('///')).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves spaces and unicode within a segment', () => {
|
||||
expect(parseStoragePath('my folder/ünïcode 📁/x')).toEqual(['my folder', 'ünïcode 📁', 'x'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeStoragePath', () => {
|
||||
it('returns an empty string for the bucket root so clearOnDefault strips the param', () => {
|
||||
expect(serializeStoragePath([])).toBe('')
|
||||
})
|
||||
|
||||
it('joins segments with a slash', () => {
|
||||
expect(serializeStoragePath(['a', 'b', 'c'])).toBe('a/b/c')
|
||||
})
|
||||
|
||||
it('drops empty segments', () => {
|
||||
expect(serializeStoragePath(['a', '', 'b'])).toBe('a/b')
|
||||
})
|
||||
|
||||
it('round-trips with parseStoragePath', () => {
|
||||
const segments = ['images', 'my folder', '2024']
|
||||
expect(parseStoragePath(serializeStoragePath(segments))).toEqual(segments)
|
||||
})
|
||||
})
|
||||
@@ -47,6 +47,19 @@ export function getPathAlongFoldersToIndex(
|
||||
.join('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a `?path` query param value into folder segments.
|
||||
*/
|
||||
export function parseStoragePath(value: string | null | undefined): string[] {
|
||||
if (!value) return []
|
||||
return value.split('/').filter((segment) => segment.length > 0)
|
||||
}
|
||||
|
||||
/** Joins folder segments into `?path`; empty at the root so nuqs' `clearOnDefault` strips it. */
|
||||
export function serializeStoragePath(paths: string[]): string {
|
||||
return paths.filter((segment) => segment.length > 0).join('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an error message string if the folder name contains invalid characters,
|
||||
* or null if the name is valid.
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { compact, get, isEmpty, uniqBy } from 'lodash'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { STORAGE_ROW_TYPES } from '../Storage.constants'
|
||||
import { ConfirmDeleteModal } from './ConfirmDeleteModal'
|
||||
import { CustomExpiryModal } from './CustomExpiryModal'
|
||||
import { FileExplorer } from './FileExplorer'
|
||||
import { FileExplorerHeader } from './FileExplorerHeader'
|
||||
import { FileExplorerHeaderSelection } from './FileExplorerHeaderSelection'
|
||||
import { MoveItemsModal } from './MoveItemsModal'
|
||||
import { PreviewPane } from './PreviewPane'
|
||||
import { useStorageExplorerNavigation } from './StorageExplorerNavigation'
|
||||
import { useStorageExplorerShortcuts } from './useStorageExplorerShortcuts'
|
||||
import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer'
|
||||
|
||||
interface StorageExplorerContentProps {
|
||||
itemSearchString: string
|
||||
setItemSearchString: (value: string) => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export const StorageExplorerContent = ({
|
||||
itemSearchString,
|
||||
setItemSearchString,
|
||||
isLoading,
|
||||
}: StorageExplorerContentProps) => {
|
||||
const {
|
||||
projectRef,
|
||||
columns,
|
||||
openedFolders,
|
||||
selectedItems,
|
||||
selectedItemsToMove,
|
||||
selectedBucket,
|
||||
fetchMoreFolderContents,
|
||||
uploadFiles,
|
||||
moveFiles,
|
||||
setSelectedItems,
|
||||
setSelectedItemsToMove,
|
||||
setIsSearching,
|
||||
} = useStorageExplorerStateSnapshot()
|
||||
const { truncateToColumn } = useStorageExplorerNavigation()
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setIsSearching(false)
|
||||
setItemSearchString('')
|
||||
}, [setIsSearching, setItemSearchString])
|
||||
|
||||
useStorageExplorerShortcuts({ onClearSearch: handleClearSearch })
|
||||
|
||||
/** Checkbox selection methods */
|
||||
/** [Joshen] We'll only support checkbox selection for files ONLY */
|
||||
|
||||
const onSelectAllItemsInColumn = (columnIndex: number) => {
|
||||
const columnFiles = columns[columnIndex].items
|
||||
.filter((item) => item.type === STORAGE_ROW_TYPES.FILE)
|
||||
.map((item) => {
|
||||
return { ...item, columnIndex }
|
||||
})
|
||||
const columnFilesId = compact(columnFiles.map((item) => item.id))
|
||||
const selectedItemsFromColumn = selectedItems.filter(
|
||||
(item) => item.id && columnFilesId.includes(item.id)
|
||||
)
|
||||
|
||||
if (selectedItemsFromColumn.length === columnFiles.length) {
|
||||
// Deselect all items from column
|
||||
const updatedSelectedItems = selectedItems.filter(
|
||||
(item) => item.id && !columnFilesId.includes(item.id)
|
||||
)
|
||||
setSelectedItems(updatedSelectedItems)
|
||||
} else {
|
||||
// Select all items from column
|
||||
const updatedSelectedItems = uniqBy(selectedItems.concat(columnFiles), 'id')
|
||||
setSelectedItems(updatedSelectedItems)
|
||||
}
|
||||
}
|
||||
|
||||
/** File manipulation methods */
|
||||
|
||||
const onFilesUpload = async (event: any, columnIndex: number = -1) => {
|
||||
event.persist()
|
||||
const items = event.target.files || event.dataTransfer.items
|
||||
const isDrop = !isEmpty(get(event, ['dataTransfer', 'items'], []))
|
||||
await uploadFiles({ files: items, columnIndex, isDrop })
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
const onMoveSelectedFiles = async (newPath: string) => {
|
||||
await moveFiles(newPath)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedItems.length === 0 ? (
|
||||
<FileExplorerHeader
|
||||
itemSearchString={itemSearchString}
|
||||
setItemSearchString={setItemSearchString}
|
||||
onFilesUpload={onFilesUpload}
|
||||
/>
|
||||
) : (
|
||||
<FileExplorerHeaderSelection />
|
||||
)}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<FileExplorer
|
||||
columns={columns}
|
||||
selectedItems={selectedItems}
|
||||
itemSearchString={itemSearchString}
|
||||
isLoading={isLoading}
|
||||
onFilesUpload={onFilesUpload}
|
||||
onSelectAllItemsInColumn={onSelectAllItemsInColumn}
|
||||
onSelectColumnEmptySpace={truncateToColumn}
|
||||
onColumnLoadMore={(index, column) =>
|
||||
fetchMoreFolderContents({ index, column, searchString: itemSearchString })
|
||||
}
|
||||
/>
|
||||
<PreviewPane />
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteModal />
|
||||
|
||||
<MoveItemsModal
|
||||
visible={selectedItemsToMove.length > 0}
|
||||
projectRef={projectRef}
|
||||
bucketId={selectedBucket.id}
|
||||
bucketName={selectedBucket.name}
|
||||
selectedItemsToMove={selectedItemsToMove}
|
||||
openedFolders={openedFolders}
|
||||
onSelectCancel={() => setSelectedItemsToMove([])}
|
||||
onSelectMove={onMoveSelectedFiles}
|
||||
/>
|
||||
|
||||
<CustomExpiryModal />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+447
@@ -0,0 +1,447 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from '../Storage.constants'
|
||||
import type { StorageItem } from '../Storage.types'
|
||||
import {
|
||||
StorageExplorerNavigationProvider,
|
||||
useStorageExplorerNavigation,
|
||||
} from './StorageExplorerNavigation'
|
||||
|
||||
const { mockUseStorageExplorerStateSnapshot } = vi.hoisted(() => ({
|
||||
mockUseStorageExplorerStateSnapshot: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/state/storage-explorer', () => ({
|
||||
useStorageExplorerStateSnapshot: () => mockUseStorageExplorerStateSnapshot(),
|
||||
}))
|
||||
|
||||
function makeFolder(name: string): StorageItem {
|
||||
return {
|
||||
id: null,
|
||||
name,
|
||||
type: STORAGE_ROW_TYPES.FOLDER,
|
||||
status: STORAGE_ROW_STATUS.READY,
|
||||
metadata: null,
|
||||
isCorrupted: false,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
last_accessed_at: null,
|
||||
}
|
||||
}
|
||||
|
||||
function makeFile(name: string): StorageItem {
|
||||
return { ...makeFolder(name), id: name, type: STORAGE_ROW_TYPES.FILE }
|
||||
}
|
||||
|
||||
function makeColumn(name: string, items: StorageItem[] = [], hasMoreItems = false) {
|
||||
return { id: name, name, path: '', status: STORAGE_ROW_STATUS.READY, items, hasMoreItems }
|
||||
}
|
||||
|
||||
/**
|
||||
* `openedFolders` excludes the bucket root, so a store at `images/2024` has three
|
||||
* columns and two opened folders.
|
||||
*/
|
||||
function createSnapshot({
|
||||
openedFolders = [] as StorageItem[],
|
||||
columns = [makeColumn('my-bucket')],
|
||||
selectedFilePreview = undefined as any,
|
||||
} = {}) {
|
||||
return {
|
||||
selectedBucket: { id: 'bucket-id', name: 'my-bucket' },
|
||||
columns,
|
||||
openedFolders,
|
||||
selectedFilePreview,
|
||||
fetchFoldersByPath: vi.fn().mockResolvedValue({ missingPaths: [] }),
|
||||
fetchFolderContents: vi.fn().mockResolvedValue(undefined),
|
||||
openFolder: vi.fn().mockResolvedValue(undefined),
|
||||
popColumn: vi.fn(),
|
||||
popColumnAtIndex: vi.fn(),
|
||||
popOpenedFolders: vi.fn(),
|
||||
popOpenedFoldersAtIndex: vi.fn(),
|
||||
clearSelectedItems: vi.fn(),
|
||||
setSelectedFilePreview: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The nuqs adapter has to sit outside the provider, so the wrapper is composed here
|
||||
* rather than going through `customRenderHook` (whose `wrapper` option would replace
|
||||
* the adapter instead of nesting inside it).
|
||||
*/
|
||||
function renderWithProvider({
|
||||
searchParams = '',
|
||||
searchString = '',
|
||||
isBucketReady = true,
|
||||
hasMemory = false,
|
||||
}: {
|
||||
searchParams?: string
|
||||
searchString?: string
|
||||
isBucketReady?: boolean
|
||||
/** Lets `setSearchParams` stand in for Back/forward or a pasted link. */
|
||||
hasMemory?: boolean
|
||||
} = {}) {
|
||||
const onUrlUpdate = vi.fn()
|
||||
let currentSearchParams = searchParams
|
||||
const utils = renderHook(() => useStorageExplorerNavigation(), {
|
||||
wrapper: ({ children }: PropsWithChildren) => (
|
||||
<NuqsTestingAdapter
|
||||
searchParams={currentSearchParams}
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
hasMemory={hasMemory}
|
||||
>
|
||||
<StorageExplorerNavigationProvider
|
||||
isBucketReady={isBucketReady}
|
||||
searchString={searchString}
|
||||
>
|
||||
{children}
|
||||
</StorageExplorerNavigationProvider>
|
||||
</NuqsTestingAdapter>
|
||||
),
|
||||
})
|
||||
return {
|
||||
...utils,
|
||||
onUrlUpdate,
|
||||
setSearchParams: (next: string) => {
|
||||
currentSearchParams = next
|
||||
utils.rerender()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('StorageExplorerNavigation', () => {
|
||||
beforeEach(() => {
|
||||
mockUseStorageExplorerStateSnapshot.mockReset()
|
||||
})
|
||||
|
||||
it('restores the column stack from ?path on mount', async () => {
|
||||
const snapshot = createSnapshot({ columns: [] })
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
|
||||
|
||||
renderWithProvider({ searchParams: '?path=images/2024' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(snapshot.fetchFoldersByPath).toHaveBeenCalledWith({
|
||||
paths: ['images', '2024'],
|
||||
searchString: '',
|
||||
showLoading: true,
|
||||
})
|
||||
})
|
||||
expect(snapshot.fetchFoldersByPath).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fetches the bucket root when there is no ?path', async () => {
|
||||
const snapshot = createSnapshot({ columns: [] })
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
|
||||
|
||||
renderWithProvider()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(snapshot.fetchFoldersByPath).toHaveBeenCalledWith({
|
||||
paths: [],
|
||||
searchString: '',
|
||||
showLoading: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fetch until the bucket is ready', async () => {
|
||||
const snapshot = createSnapshot({ columns: [] })
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
|
||||
|
||||
renderWithProvider({ searchParams: '?path=images', isBucketReady: false })
|
||||
|
||||
await Promise.resolve()
|
||||
expect(snapshot.fetchFoldersByPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drills down through the store without re-fetching the whole path', async () => {
|
||||
// Store and URL already agree on `images`; opening a child must not trigger a restore.
|
||||
const snapshot = createSnapshot({
|
||||
openedFolders: [makeFolder('images')],
|
||||
columns: [makeColumn('my-bucket'), makeColumn('images')],
|
||||
})
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
|
||||
|
||||
const { result } = renderWithProvider({ searchParams: '?path=images' })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.openFolderAtIndex(1, makeFolder('2024'))
|
||||
})
|
||||
|
||||
expect(snapshot.openFolder).toHaveBeenCalledTimes(1)
|
||||
expect(snapshot.openFolder).toHaveBeenCalledWith(1, expect.objectContaining({ name: '2024' }))
|
||||
expect(snapshot.fetchFoldersByPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes the URL when jumping to a path and lets the restore effect fetch it', async () => {
|
||||
const snapshot = createSnapshot({
|
||||
openedFolders: [makeFolder('images')],
|
||||
columns: [makeColumn('my-bucket'), makeColumn('images')],
|
||||
})
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
|
||||
|
||||
const { result, onUrlUpdate } = renderWithProvider({ searchParams: '?path=images' })
|
||||
|
||||
act(() => {
|
||||
result.current.navigateToPath(['archive', '2025'])
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const [update] = onUrlUpdate.mock.calls.at(-1)!
|
||||
expect(update.queryString).toContain('path=archive/2025')
|
||||
expect(update.options.history).toBe('push')
|
||||
})
|
||||
|
||||
it('falls back to the bucket root when a path segment no longer exists', async () => {
|
||||
const snapshot = createSnapshot({ columns: [] })
|
||||
snapshot.fetchFoldersByPath
|
||||
.mockResolvedValueOnce({ missingPaths: ['2024'] })
|
||||
.mockResolvedValue({ missingPaths: [] })
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
|
||||
|
||||
const { onUrlUpdate } = renderWithProvider({ searchParams: '?path=images/2024' })
|
||||
|
||||
// Re-fetches the root rather than leaving the store on the dead path
|
||||
await waitFor(() => {
|
||||
expect(snapshot.fetchFoldersByPath).toHaveBeenCalledWith({
|
||||
paths: [],
|
||||
searchString: '',
|
||||
showLoading: true,
|
||||
})
|
||||
})
|
||||
// ...and corrects the URL to match, without a history entry
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const [update] = onUrlUpdate.mock.calls.at(-1)!
|
||||
expect(update.queryString).not.toContain('path=')
|
||||
expect(update.options.history).toBe('replace')
|
||||
})
|
||||
|
||||
it('leaves ?preview alone when the listing is incomplete', async () => {
|
||||
// The file may simply be on a later page — dropping the param would lose the deep link.
|
||||
const snapshot = createSnapshot({
|
||||
columns: [makeColumn('my-bucket', [makeFile('other.png')], true)],
|
||||
})
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
|
||||
|
||||
const { onUrlUpdate } = renderWithProvider({ searchParams: '?preview=a.png' })
|
||||
|
||||
await Promise.resolve()
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled()
|
||||
expect(snapshot.setSelectedFilePreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves ?preview once a later page brings the file in', async () => {
|
||||
// The file sits beyond the first LIMIT-sized page, so it is absent from the initial
|
||||
// listing. The param is kept, and the preview opens when pagination loads it.
|
||||
const snapshot = createSnapshot({
|
||||
columns: [makeColumn('my-bucket', [makeFile('other.png')], true)],
|
||||
})
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
|
||||
|
||||
const { rerender } = renderWithProvider({ searchParams: '?preview=a.png' })
|
||||
expect(snapshot.setSelectedFilePreview).not.toHaveBeenCalled()
|
||||
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue({
|
||||
...snapshot,
|
||||
columns: [makeColumn('my-bucket', [makeFile('other.png'), makeFile('a.png')], false)],
|
||||
})
|
||||
await act(async () => {
|
||||
rerender()
|
||||
})
|
||||
|
||||
expect(snapshot.setSelectedFilePreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'a.png' })
|
||||
)
|
||||
})
|
||||
|
||||
it('drops ?preview when the file is gone from a complete listing', async () => {
|
||||
const snapshot = createSnapshot({
|
||||
columns: [makeColumn('my-bucket', [makeFile('other.png')])],
|
||||
})
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
|
||||
|
||||
const { onUrlUpdate } = renderWithProvider({ searchParams: '?preview=a.png' })
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const [update] = onUrlUpdate.mock.calls.at(-1)!
|
||||
expect(update.queryString).not.toContain('preview=')
|
||||
})
|
||||
|
||||
it('records an opened preview in ?preview without adding a history entry', async () => {
|
||||
const snapshot = createSnapshot({
|
||||
columns: [makeColumn('my-bucket', [makeFile('a.png')])],
|
||||
})
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
|
||||
|
||||
const { result, onUrlUpdate } = renderWithProvider()
|
||||
|
||||
act(() => {
|
||||
result.current.setPreviewedFile({ ...makeFile('a.png'), columnIndex: 0 })
|
||||
})
|
||||
|
||||
expect(snapshot.setSelectedFilePreview).toHaveBeenCalled()
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const [update] = onUrlUpdate.mock.calls.at(-1)!
|
||||
expect(update.queryString).toContain('preview=a.png')
|
||||
expect(update.options.history).toBe('replace')
|
||||
})
|
||||
|
||||
it('restores a previewed file from ?preview once its column has loaded', async () => {
|
||||
const snapshot = createSnapshot({
|
||||
columns: [makeColumn('my-bucket', [makeFile('a.png')])],
|
||||
})
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
|
||||
|
||||
renderWithProvider({ searchParams: '?preview=a.png' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(snapshot.setSelectedFilePreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'a.png', columnIndex: 0 })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let a stale ?preview revert a freshly previewed file', async () => {
|
||||
// Clicking a file mutates the store synchronously; the URL is written a render later.
|
||||
// On that in-between render `?preview` still names the *previous* file, which is very
|
||||
// much still in the column — so without a "which side moved" guard this effect finds
|
||||
// it and pushes the preview back, undoing the click.
|
||||
const columns = [makeColumn('my-bucket', [makeFile('a.png'), makeFile('b.png')])]
|
||||
const snapshot = createSnapshot({
|
||||
columns,
|
||||
selectedFilePreview: { ...makeFile('a.png'), columnIndex: 0 },
|
||||
})
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
|
||||
|
||||
const { rerender } = renderWithProvider({ searchParams: '?preview=a.png' })
|
||||
snapshot.setSelectedFilePreview.mockClear()
|
||||
|
||||
// Store now previews b.png while the URL still says a.png
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue({
|
||||
...snapshot,
|
||||
selectedFilePreview: { ...makeFile('b.png'), columnIndex: 0 },
|
||||
})
|
||||
await act(async () => {
|
||||
rerender()
|
||||
})
|
||||
|
||||
expect(snapshot.setSelectedFilePreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('collapses the stack and records the file in a single URL write', async () => {
|
||||
// The file sits in the bucket root while the store is a level deeper, so selecting it
|
||||
// has to drop `path` and add `preview` at once — two writes would leave `images`
|
||||
// paired with a root-level file in history.
|
||||
const snapshot = createSnapshot({
|
||||
openedFolders: [makeFolder('images')],
|
||||
columns: [makeColumn('my-bucket', [makeFile('a.png')]), makeColumn('images')],
|
||||
})
|
||||
snapshot.popColumnAtIndex.mockImplementation((index: number) => {
|
||||
snapshot.columns = snapshot.columns.slice(0, index + 1)
|
||||
})
|
||||
snapshot.popOpenedFoldersAtIndex.mockImplementation((index: number) => {
|
||||
snapshot.openedFolders = snapshot.openedFolders.slice(0, index + 1)
|
||||
})
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
|
||||
|
||||
const { result, onUrlUpdate } = renderWithProvider({ searchParams: '?path=images' })
|
||||
|
||||
act(() => {
|
||||
result.current.setPreviewedFile({ ...makeFile('a.png'), columnIndex: 0 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
expect(onUrlUpdate).toHaveBeenCalledTimes(1)
|
||||
const [update] = onUrlUpdate.mock.calls.at(-1)!
|
||||
expect(update.queryString).toContain('preview=a.png')
|
||||
expect(update.queryString).not.toContain('path=images')
|
||||
// Collapsing columns is a navigation, so Back returns to the deeper folder
|
||||
expect(update.options.history).toBe('push')
|
||||
expect(snapshot.popColumnAtIndex).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('applies a ?path change that lands mid-restore instead of overwriting it', async () => {
|
||||
const releases: (() => void)[] = []
|
||||
const snapshot = createSnapshot({ columns: [] })
|
||||
snapshot.fetchFoldersByPath.mockImplementation(async ({ paths }: { paths: string[] }) => {
|
||||
await new Promise<void>((resolve) => releases.push(resolve))
|
||||
// A restore always lands the store on the path it was started for
|
||||
snapshot.openedFolders = paths.map(makeFolder)
|
||||
snapshot.columns = [makeColumn('my-bucket'), ...paths.map((path) => makeColumn(path))]
|
||||
return { missingPaths: [] }
|
||||
})
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
|
||||
|
||||
const { onUrlUpdate, rerender, setSearchParams } = renderWithProvider({
|
||||
searchParams: '?path=images/2024',
|
||||
hasMemory: true,
|
||||
})
|
||||
await waitFor(() => expect(snapshot.fetchFoldersByPath).toHaveBeenCalledTimes(1))
|
||||
|
||||
// Back, while the first restore is still in flight
|
||||
act(() => setSearchParams('?path=images'))
|
||||
await act(async () => {
|
||||
releases.shift()!()
|
||||
})
|
||||
act(() => rerender())
|
||||
|
||||
// Finishing the first restore must not write `images/2024` back over the newer URL
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled()
|
||||
await waitFor(() => expect(snapshot.fetchFoldersByPath).toHaveBeenCalledTimes(2))
|
||||
expect(snapshot.fetchFoldersByPath).toHaveBeenLastCalledWith({
|
||||
paths: ['images'],
|
||||
searchString: '',
|
||||
showLoading: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('swaps the preview when ?preview names a same-named file in another folder', async () => {
|
||||
const snapshot = createSnapshot({
|
||||
openedFolders: [makeFolder('archive')],
|
||||
columns: [
|
||||
makeColumn('my-bucket'),
|
||||
makeColumn('archive', [{ ...makeFile('photo.png'), id: 'archive/photo.png' }]),
|
||||
],
|
||||
selectedFilePreview: { ...makeFile('photo.png'), id: 'images/photo.png', columnIndex: 1 },
|
||||
})
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
|
||||
|
||||
renderWithProvider({ searchParams: '?path=archive&preview=photo.png' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(snapshot.setSelectedFilePreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'archive/photo.png', columnIndex: 1 })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves ?preview alone while a search is narrowing the folder listing', async () => {
|
||||
const snapshot = createSnapshot({
|
||||
columns: [makeColumn('my-bucket', [makeFile('other.png')])],
|
||||
})
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
|
||||
|
||||
const { onUrlUpdate } = renderWithProvider({
|
||||
searchParams: '?preview=a.png',
|
||||
searchString: 'other',
|
||||
})
|
||||
|
||||
await act(async () => {})
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled()
|
||||
expect(snapshot.setSelectedFilePreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refetches the bucket root when switching buckets without a path', async () => {
|
||||
// The provider is keyed per project, not per bucket, so the store can still hold
|
||||
// the previous bucket's columns.
|
||||
const snapshot = createSnapshot({ columns: [makeColumn('another-bucket')] })
|
||||
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
|
||||
|
||||
renderWithProvider()
|
||||
|
||||
await waitFor(() => expect(snapshot.fetchFoldersByPath).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
useRef,
|
||||
type PropsWithChildren,
|
||||
} from 'react'
|
||||
|
||||
import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from '../Storage.constants'
|
||||
import type { StorageItem, StorageItemWithColumn } from '../Storage.types'
|
||||
import { getPathAlongOpenedFolders, parseStoragePath } from './StorageExplorer.utils'
|
||||
import { useStorageExplorerUrlState } from './useStorageExplorerUrlState'
|
||||
import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer'
|
||||
|
||||
interface StorageExplorerNavigationContextValue {
|
||||
openFolderAtIndex: (columnIndex: number, folder: StorageItem) => Promise<void>
|
||||
navigateToPath: (paths: string[]) => void
|
||||
goUpOneLevel: () => void
|
||||
truncateToColumn: (columnIndex: number) => void
|
||||
setPreviewedFile: (item: StorageItemWithColumn) => void
|
||||
clearPreviewedFile: () => void
|
||||
}
|
||||
|
||||
const StorageExplorerNavigationContext =
|
||||
createContext<StorageExplorerNavigationContextValue | null>(null)
|
||||
|
||||
export const useStorageExplorerNavigation = () => {
|
||||
const context = useContext(StorageExplorerNavigationContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useStorageExplorerNavigation must be used within a StorageExplorerNavigationProvider'
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface StorageExplorerNavigationProviderProps {
|
||||
isBucketReady: boolean
|
||||
searchString: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs `?path` / `?preview` with the valtio store, which stays authoritative. Whichever
|
||||
* side moved last decides the direction — one writer each way is what stops it looping.
|
||||
*/
|
||||
export const StorageExplorerNavigationProvider = ({
|
||||
isBucketReady,
|
||||
searchString,
|
||||
children,
|
||||
}: PropsWithChildren<StorageExplorerNavigationProviderProps>) => {
|
||||
const snap = useStorageExplorerStateSnapshot()
|
||||
const { urlPath, urlFolderPaths, urlPreview, setUrlLocation, setUrlPreview } =
|
||||
useStorageExplorerUrlState()
|
||||
|
||||
const storePath = getPathAlongOpenedFolders(snap, false)
|
||||
const bucketName = snap.selectedBucket.name
|
||||
const rootColumnName = snap.columns[0]?.name
|
||||
const previewedFileName = snap.selectedFilePreview?.name
|
||||
const previewedFileId = snap.selectedFilePreview?.id
|
||||
const lastColumn = snap.columns[snap.columns.length - 1]
|
||||
const isLastColumnReady = lastColumn?.status === STORAGE_ROW_STATUS.READY
|
||||
|
||||
const previousStorePathRef = useRef(storePath)
|
||||
const previousSearchStringRef = useRef<string | null>(null)
|
||||
/** Marks a store move as deliberate navigation (push), not a mutation side effect (replace). */
|
||||
const navigationHistoryModeRef = useRef<'push' | null>(null)
|
||||
const previousPreviewRef = useRef(previewedFileName)
|
||||
/** The `?path` a restore is in flight for; `openedFolders` only catches up when it resolves. */
|
||||
const restoringPathRef = useRef<string | null>(null)
|
||||
/** A `?path` change that landed mid-restore, so it isn't mistaken for a newer one. */
|
||||
const hasSupersededRestoreRef = useRef(false)
|
||||
|
||||
const restoreFromUrl = useEffectEvent(async () => {
|
||||
restoringPathRef.current = urlPath
|
||||
hasSupersededRestoreRef.current = false
|
||||
try {
|
||||
const { missingPaths } = await snap.fetchFoldersByPath({
|
||||
paths: urlFolderPaths,
|
||||
searchString,
|
||||
showLoading: true,
|
||||
})
|
||||
// Skip the fallback once a newer URL has superseded this pass; its own restore runs.
|
||||
if (missingPaths.length > 0 && !hasSupersededRestoreRef.current) {
|
||||
// Dead folder path: reset store and URL together, or the reconcile effect writes
|
||||
// the dead path straight back.
|
||||
await snap.fetchFoldersByPath({ paths: [], searchString, showLoading: true })
|
||||
setUrlLocation({ paths: [], preview: null }, { history: 'replace' })
|
||||
}
|
||||
} finally {
|
||||
restoringPathRef.current = null
|
||||
}
|
||||
})
|
||||
|
||||
const refetchForSearch = useEffectEvent(async () => {
|
||||
// Only the folder you are standing in is refetched — passing the term to every open
|
||||
// column (as the old fetch did) made the current folder vanish from its own parent.
|
||||
const currentIndex = snap.openedFolders.length - 1
|
||||
const currentFolder = snap.openedFolders[currentIndex]
|
||||
await snap.fetchFolderContents({
|
||||
bucketId: snap.selectedBucket.id,
|
||||
folderId: currentFolder?.id ?? snap.selectedBucket.id,
|
||||
folderName: currentFolder?.name ?? snap.selectedBucket.name,
|
||||
index: currentFolder ? currentIndex : -1,
|
||||
searchString,
|
||||
})
|
||||
})
|
||||
|
||||
const reconcileUrlToStore = useEffectEvent((history: 'push' | 'replace') => {
|
||||
setUrlLocation(
|
||||
{ paths: parseStoragePath(storePath), preview: snap.selectedFilePreview?.name ?? null },
|
||||
{ history }
|
||||
)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBucketReady) return
|
||||
if (restoringPathRef.current !== null) {
|
||||
// Hold on to a URL change that landed mid-restore; the pass after it is where it applies.
|
||||
if (urlPath !== restoringPathRef.current) hasSupersededRestoreRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
const hasStoreChanged = storePath !== previousStorePathRef.current
|
||||
previousStorePathRef.current = storePath
|
||||
|
||||
const hasSearchChanged = searchString !== previousSearchStringRef.current
|
||||
previousSearchStringRef.current = searchString
|
||||
|
||||
// A superseded restore means the URL, not the store, is the side that moved last.
|
||||
const hasUrlSupersededStore = hasSupersededRestoreRef.current
|
||||
hasSupersededRestoreRef.current = false
|
||||
|
||||
// Nothing loaded yet, or the store still holds the bucket we navigated away from —
|
||||
// the provider is keyed per project, so a bucket switch does not remount it.
|
||||
const isStoreEmpty = snap.columns.length === 0
|
||||
const isStoreOnAnotherBucket = rootColumnName !== bucketName
|
||||
if (isStoreEmpty || isStoreOnAnotherBucket) {
|
||||
restoreFromUrl()
|
||||
return
|
||||
}
|
||||
|
||||
if (storePath !== urlPath) {
|
||||
if (hasStoreChanged && !hasUrlSupersededStore) {
|
||||
const history = navigationHistoryModeRef.current ?? 'replace'
|
||||
navigationHistoryModeRef.current = null
|
||||
reconcileUrlToStore(history)
|
||||
return
|
||||
}
|
||||
restoreFromUrl()
|
||||
return
|
||||
}
|
||||
|
||||
navigationHistoryModeRef.current = null
|
||||
|
||||
if (hasSearchChanged) refetchForSearch()
|
||||
}, [
|
||||
isBucketReady,
|
||||
urlPath,
|
||||
storePath,
|
||||
searchString,
|
||||
bucketName,
|
||||
rootColumnName,
|
||||
snap.columns.length,
|
||||
])
|
||||
|
||||
// Restoring `?preview` needs the full item, which only exists once its column has loaded.
|
||||
useEffect(() => {
|
||||
if (!isBucketReady || !isLastColumnReady) return
|
||||
|
||||
const hasPreviewChangedInStore = previewedFileName !== previousPreviewRef.current
|
||||
previousPreviewRef.current = previewedFileName
|
||||
if (hasPreviewChangedInStore) return
|
||||
|
||||
if (!urlPreview) {
|
||||
if (previewedFileName) snap.setSelectedFilePreview(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const item = lastColumn.items.find(
|
||||
(columnItem) => columnItem.name === urlPreview && columnItem.type === STORAGE_ROW_TYPES.FILE
|
||||
)
|
||||
if (!item) {
|
||||
const isListingComplete = !searchString && !lastColumn.hasMoreItems
|
||||
if (isListingComplete) setUrlPreview(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (previewedFileId === item.id) return
|
||||
|
||||
snap.setSelectedFilePreview({ ...item, columnIndex: snap.columns.length - 1 })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
isBucketReady,
|
||||
isLastColumnReady,
|
||||
urlPreview,
|
||||
previewedFileName,
|
||||
previewedFileId,
|
||||
lastColumn?.path,
|
||||
lastColumn?.items.length,
|
||||
searchString,
|
||||
])
|
||||
|
||||
const context = useMemo(() => {
|
||||
const openFolderAtIndex = async (columnIndex: number, folder: StorageItem) => {
|
||||
navigationHistoryModeRef.current = 'push'
|
||||
await snap.openFolder(columnIndex, folder)
|
||||
}
|
||||
|
||||
const goUpOneLevel = () => {
|
||||
navigationHistoryModeRef.current = 'push'
|
||||
snap.popColumn()
|
||||
snap.popOpenedFolders()
|
||||
snap.setSelectedFilePreview(undefined)
|
||||
}
|
||||
|
||||
const truncateToColumn = (columnIndex: number) => {
|
||||
navigationHistoryModeRef.current = 'push'
|
||||
snap.popColumnAtIndex(columnIndex)
|
||||
snap.popOpenedFoldersAtIndex(columnIndex - 1)
|
||||
snap.setSelectedFilePreview(undefined)
|
||||
snap.clearSelectedItems()
|
||||
}
|
||||
|
||||
const navigateToPath = (paths: string[]) => {
|
||||
setUrlLocation({ paths, preview: null }, { history: 'push' })
|
||||
}
|
||||
|
||||
const setPreviewedFile = (item: StorageItemWithColumn) => {
|
||||
const paths = snap.openedFolders.slice(0, item.columnIndex).map((folder) => folder.name)
|
||||
// Collapsing back to the file's own column is a navigation; previewing in place isn't.
|
||||
const isCollapsingColumns = item.columnIndex < snap.openedFolders.length
|
||||
|
||||
snap.popColumnAtIndex(item.columnIndex)
|
||||
snap.popOpenedFoldersAtIndex(item.columnIndex - 1)
|
||||
snap.clearSelectedItems()
|
||||
snap.setSelectedFilePreview(item)
|
||||
// One write, so the URL never pairs the new file with the old, deeper path — a
|
||||
// separate `preview` write would leave exactly that pairing behind in history.
|
||||
setUrlLocation(
|
||||
{ paths, preview: item.name },
|
||||
{ history: isCollapsingColumns ? 'push' : 'replace' }
|
||||
)
|
||||
}
|
||||
|
||||
const clearPreviewedFile = () => {
|
||||
snap.setSelectedFilePreview(undefined)
|
||||
setUrlPreview(null)
|
||||
}
|
||||
|
||||
return {
|
||||
openFolderAtIndex,
|
||||
navigateToPath,
|
||||
goUpOneLevel,
|
||||
truncateToColumn,
|
||||
setPreviewedFile,
|
||||
clearPreviewedFile,
|
||||
}
|
||||
}, [setUrlLocation, setUrlPreview, snap])
|
||||
|
||||
return (
|
||||
<StorageExplorerNavigationContext.Provider value={context}>
|
||||
{children}
|
||||
</StorageExplorerNavigationContext.Provider>
|
||||
)
|
||||
}
|
||||
+6
-8
@@ -1,5 +1,6 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { useStorageExplorerNavigation } from './StorageExplorerNavigation'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
||||
@@ -24,12 +25,13 @@ export function useStorageExplorerShortcuts({ onClearSearch }: UseStorageExplore
|
||||
clearSelectedItems,
|
||||
setSelectedItemsToDelete,
|
||||
setSelectedItemsToMove,
|
||||
setSelectedFilePreview,
|
||||
downloadFile,
|
||||
downloadSelectedFiles,
|
||||
refreshAll,
|
||||
} = useStorageExplorerStateSnapshot()
|
||||
|
||||
const { clearPreviewedFile } = useStorageExplorerNavigation()
|
||||
|
||||
const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
|
||||
|
||||
useShortcut(SHORTCUT_IDS.STORAGE_EXPLORER_REFRESH, () => {
|
||||
@@ -64,13 +66,9 @@ export function useStorageExplorerShortcuts({ onClearSearch }: UseStorageExplore
|
||||
enabled: selectedItems.length > 0,
|
||||
})
|
||||
|
||||
useShortcut(
|
||||
SHORTCUT_IDS.STORAGE_EXPLORER_CLOSE_PREVIEW,
|
||||
() => setSelectedFilePreview(undefined),
|
||||
{
|
||||
enabled: selectedItems.length === 0 && !!selectedFilePreview,
|
||||
}
|
||||
)
|
||||
useShortcut(SHORTCUT_IDS.STORAGE_EXPLORER_CLOSE_PREVIEW, clearPreviewedFile, {
|
||||
enabled: selectedItems.length === 0 && !!selectedFilePreview,
|
||||
})
|
||||
|
||||
useShortcut(SHORTCUT_IDS.STORAGE_EXPLORER_CLOSE_SEARCH, onClearSearch, {
|
||||
enabled: selectedItems.length === 0 && !selectedFilePreview && isSearching,
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { parseAsString, useQueryStates, type Options } from 'nuqs'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { parseStoragePath, serializeStoragePath } from './StorageExplorer.utils'
|
||||
|
||||
/**
|
||||
* Owns the two query params that address a location inside a bucket:
|
||||
* - `path` — the slash-joined folder path, e.g. `?path=images/2024`
|
||||
* - `preview` — the name of the file whose preview panel is open in that folder
|
||||
*
|
||||
* Both live in a single `useQueryStates` so that opening a folder can clear `preview`
|
||||
* and set `path` in one atomic URL write. Two separate `useQueryState` calls would push two
|
||||
* history entries per drill-down, which makes the Back button useless.
|
||||
*
|
||||
* `path` pushes history — Back should walk back up the folder chain. `preview` replaces,
|
||||
* because opening or closing a panel is not a navigation.
|
||||
*/
|
||||
export function useStorageExplorerUrlState() {
|
||||
const [{ path, preview }, setParams] = useQueryStates(
|
||||
{ path: parseAsString.withDefault(''), preview: parseAsString.withDefault('') },
|
||||
{ history: 'push', clearOnDefault: true }
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
urlPath: path,
|
||||
urlFolderPaths: parseStoragePath(path),
|
||||
urlPreview: preview,
|
||||
|
||||
/**
|
||||
* Writes both params at once. `preview` is always passed explicitly so a location
|
||||
* write can carry an open preview along with it rather than clearing it.
|
||||
*/
|
||||
setUrlLocation: (
|
||||
{ paths, preview: previewedName }: { paths: string[]; preview: string | null },
|
||||
options?: Options
|
||||
) => setParams({ path: serializeStoragePath(paths), preview: previewedName ?? '' }, options),
|
||||
|
||||
setUrlPreview: (name: string | null) =>
|
||||
setParams({ preview: name ?? '' }, { history: 'replace' }),
|
||||
}),
|
||||
[path, preview, setParams]
|
||||
)
|
||||
}
|
||||
@@ -176,7 +176,9 @@ export const uploadFile = async (page: Page, filePath: string, fileName: string)
|
||||
const fileInput = page.locator('input[type="file"]')
|
||||
await fileInput.setInputFiles(filePath)
|
||||
|
||||
await expect(page.getByRole('status')).not.toBeVisible()
|
||||
// Wait out the upload progress toast. Scoped to the toast itself — a page-wide
|
||||
// `getByRole('status')` also matches any live region the explorer renders.
|
||||
await expect(page.locator('[data-sonner-toast]')).not.toBeVisible()
|
||||
// Verify file appears in the explorer by title
|
||||
await expect(
|
||||
page.getByTitle(fileName),
|
||||
|
||||
Reference in new issue
Block a user