mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio): add copy path and copy link row actions (#50480)
| | PR | Base | Branch | | --- | --- | --- | --- | | 1 | #50476 | `master` | pre-existing correctness fixes | | 2 | #50413 | `fix/storage-explorer-listing-and-scroll` | `?path`/`?preview` deep-linking | | 3 | #50478 | `feat/storage-nav-improvement` | end-to-end deep-link test | | 4 | **this PR** | `test/storage-deep-link-e2e` | copy path / copy link row actions | To read the whole change in one view: ```bash git diff master...feat/storage-copy-row-actions -- apps/studio e2e ``` ## What is the new behavior? Both row menus now offer two actions: - **Copy relative path** — the bucket-relative object key, i.e. what `storage.from(bucket)` takes - **Copy link** — the dashboard URL that reopens the item in the explorer **Copy path to folder** is replaced by **Copy relative path**. It produces the same value for a folder and now works for files too, so nothing is lost. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Storage Explorer now provides separate actions to copy a relative path or a direct link for files and folders. * Copied links open the relevant storage location, including folder navigation and file preview details. * Success notifications appear after clipboard copying completes. * **Tests** * Added coverage for file and folder copy actions, generated paths and links, URL encoding, and clipboard behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
cdbe2963fa
commit
47a532eef7
4 files changed
+314
-15
No files matched your search
@@ -0,0 +1,93 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
STORAGE_ROW_STATUS,
|
||||
STORAGE_ROW_TYPES,
|
||||
STORAGE_VIEWS,
|
||||
} from '@/components/interfaces/Storage/Storage.constants'
|
||||
import { FileExplorerRow } from '@/components/interfaces/Storage/StorageExplorer/FileExplorerRow'
|
||||
import { customRender as render } from '@/tests/lib/custom-render'
|
||||
|
||||
vi.mock('@/state/storage-explorer', () => ({
|
||||
useStorageExplorerStateSnapshot: () => ({
|
||||
projectRef: 'abcdef',
|
||||
selectedBucket: { id: 'my-bucket', name: 'my-bucket', public: false },
|
||||
selectedFilePreview: undefined,
|
||||
openedFolders: [],
|
||||
setSelectedFileCustomExpiry: vi.fn(),
|
||||
setSelectedItems: vi.fn(),
|
||||
setSelectedItemsToDelete: vi.fn(),
|
||||
downloadFile: vi.fn(),
|
||||
setSelectedItemToRename: vi.fn(),
|
||||
setSelectedItemsToMove: vi.fn(),
|
||||
downloadFolder: vi.fn(),
|
||||
selectRangeItems: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation', () => ({
|
||||
useStorageExplorerNavigation: () => ({
|
||||
openFolderAtIndex: vi.fn(),
|
||||
truncateToColumn: vi.fn(),
|
||||
setPreviewedFile: vi.fn(),
|
||||
clearPreviewedFile: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/hooks/misc/useCheckPermissions', () => ({
|
||||
useAsyncCheckPermissions: () => ({ can: true }),
|
||||
}))
|
||||
vi.mock('@/components/interfaces/Storage/StorageExplorer/useCopyUrl', () => ({
|
||||
useCopyUrl: () => ({ onCopyUrl: vi.fn() }),
|
||||
}))
|
||||
|
||||
const base = {
|
||||
status: STORAGE_ROW_STATUS.READY,
|
||||
metadata: { size: 10, mimetype: 'image/png' },
|
||||
isCorrupted: false,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
last_accessed_at: null,
|
||||
path: 'photo.png',
|
||||
}
|
||||
|
||||
describe('FileExplorerRow', () => {
|
||||
it('offers both the relative path and the dashboard URL for a file', async () => {
|
||||
render(
|
||||
<FileExplorerRow
|
||||
item={{ ...base, id: 'f1', name: 'photo.png', type: STORAGE_ROW_TYPES.FILE } as any}
|
||||
index={0}
|
||||
view={STORAGE_VIEWS.COLUMNS}
|
||||
columnIndex={0}
|
||||
selectedItems={[]}
|
||||
/>
|
||||
)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'photo.png actions' }))
|
||||
expect(await screen.findByText('Copy relative path')).toBeInTheDocument()
|
||||
expect(screen.getByText('Copy link')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers both the relative path and the dashboard URL for a folder', async () => {
|
||||
render(
|
||||
<FileExplorerRow
|
||||
item={
|
||||
{
|
||||
...base,
|
||||
id: null,
|
||||
name: 'avatars',
|
||||
type: STORAGE_ROW_TYPES.FOLDER,
|
||||
metadata: null,
|
||||
} as any
|
||||
}
|
||||
index={0}
|
||||
view={STORAGE_VIEWS.COLUMNS}
|
||||
columnIndex={0}
|
||||
selectedItems={[]}
|
||||
/>
|
||||
)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'avatars actions' }))
|
||||
expect(await screen.findByText('Copy relative path')).toBeInTheDocument()
|
||||
expect(screen.getByText('Copy link')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Copy path to folder')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -5,12 +5,13 @@ import {
|
||||
Copy,
|
||||
Download,
|
||||
Edit,
|
||||
Link2,
|
||||
LoaderCircle,
|
||||
MoreVertical,
|
||||
Move,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import {
|
||||
Checkbox,
|
||||
cn,
|
||||
@@ -38,7 +39,7 @@ import { StorageItemWithColumn, type StorageItem } from '../Storage.types'
|
||||
import { StorageRowIcon } from '../StorageRowIcon'
|
||||
import { useFileExplorerContextMenu } from './FileExplorerRowContextMenu'
|
||||
import { FileExplorerRowEditing } from './FileExplorerRowEditing'
|
||||
import { copyPathToFolder } from './StorageExplorer.utils'
|
||||
import { copyStorageExplorerUrl, copyStoragePath } from './StorageExplorer.utils'
|
||||
import { useStorageExplorerNavigation } from './StorageExplorerNavigation'
|
||||
import { useCopyUrl } from './useCopyUrl'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
@@ -54,6 +55,13 @@ interface FileExplorerRowProps {
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
type RowOption = {
|
||||
name: string
|
||||
icon?: ReactNode
|
||||
onClick?: () => void
|
||||
children?: { name: string; onClick: () => void }[]
|
||||
}
|
||||
|
||||
export const FileExplorerRow = ({
|
||||
index: itemIndex,
|
||||
item,
|
||||
@@ -63,6 +71,7 @@ export const FileExplorerRow = ({
|
||||
style,
|
||||
}: FileExplorerRowProps) => {
|
||||
const {
|
||||
projectRef,
|
||||
selectedBucket,
|
||||
selectedFilePreview,
|
||||
openedFolders,
|
||||
@@ -103,7 +112,26 @@ export const FileExplorerRow = ({
|
||||
clearPreviewedFile()
|
||||
}
|
||||
|
||||
const rowOptions =
|
||||
const copyPathOptions: RowOption[] = [
|
||||
{
|
||||
name: 'Copy relative path',
|
||||
icon: <Copy size={12} className="text-foreground-light" />,
|
||||
onClick: () => copyStoragePath(openedFolders, itemWithColumnIndex),
|
||||
},
|
||||
{
|
||||
name: 'Copy link',
|
||||
icon: <Link2 size={12} className="text-foreground-light" />,
|
||||
onClick: () =>
|
||||
copyStorageExplorerUrl({
|
||||
openedFolders,
|
||||
item: itemWithColumnIndex,
|
||||
projectRef,
|
||||
bucketId: selectedBucket.id,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
const rowOptions: RowOption[] =
|
||||
item.type === STORAGE_ROW_TYPES.FOLDER
|
||||
? [
|
||||
...(canUpdateFiles
|
||||
@@ -120,11 +148,7 @@ export const FileExplorerRow = ({
|
||||
icon: <Download size={12} className="text-foreground-light" />,
|
||||
onClick: () => downloadFolder(itemWithColumnIndex),
|
||||
},
|
||||
{
|
||||
name: 'Copy path to folder',
|
||||
icon: <Copy size={12} className="text-foreground-light" />,
|
||||
onClick: () => copyPathToFolder(openedFolders, itemWithColumnIndex),
|
||||
},
|
||||
...copyPathOptions,
|
||||
...(canUpdateFiles
|
||||
? [
|
||||
{ name: 'Separator', icon: undefined, onClick: undefined },
|
||||
@@ -181,6 +205,7 @@ export const FileExplorerRow = ({
|
||||
icon: <Download size={12} className="text-foreground-light" />,
|
||||
onClick: () => downloadFile(itemWithColumnIndex),
|
||||
},
|
||||
...copyPathOptions,
|
||||
...(canUpdateFiles
|
||||
? [
|
||||
{
|
||||
|
||||
+135
-1
@@ -1,4 +1,5 @@
|
||||
import { toast } from 'sonner'
|
||||
import { copyToClipboard } from 'ui'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
@@ -7,8 +8,12 @@ import {
|
||||
} from '@/components/interfaces/Storage/Storage.constants'
|
||||
import type { StorageItem } from '@/components/interfaces/Storage/Storage.types'
|
||||
import {
|
||||
copyStorageExplorerUrl,
|
||||
copyStoragePath,
|
||||
getPathAlongFoldersToIndex,
|
||||
getPathAlongOpenedFolders,
|
||||
getStorageExplorerUrlForItem,
|
||||
getStoragePathForItem,
|
||||
parseStoragePath,
|
||||
sanitizeNameForDuplicateInColumn,
|
||||
serializeStoragePath,
|
||||
@@ -159,7 +164,8 @@ describe('getPathAlongFoldersToIndex', () => {
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
||||
vi.mock('ui', () => ({ copyToClipboard: vi.fn() }))
|
||||
|
||||
describe('sanitizeNameForDuplicateInColumn', () => {
|
||||
// Reset mock call counts between tests
|
||||
@@ -318,3 +324,131 @@ describe('serializeStoragePath', () => {
|
||||
expect(parseStoragePath(serializeStoragePath(segments))).toEqual(segments)
|
||||
})
|
||||
})
|
||||
|
||||
function makeFile(name: string): StorageItem {
|
||||
return { ...makeFolder(name), id: name, type: STORAGE_ROW_TYPES.FILE }
|
||||
}
|
||||
|
||||
describe('getStoragePathForItem', () => {
|
||||
it('returns just the name at the bucket root', () => {
|
||||
expect(getStoragePathForItem([], { ...makeFile('photo.png'), columnIndex: 0 })).toBe(
|
||||
'photo.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('joins the opened folder chain above the item', () => {
|
||||
const openedFolders = [makeFolder('avatars'), makeFolder('2024')]
|
||||
expect(getStoragePathForItem(openedFolders, { ...makeFile('photo.png'), columnIndex: 2 })).toBe(
|
||||
'avatars/2024/photo.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the same shape for folders', () => {
|
||||
const openedFolders = [makeFolder('avatars')]
|
||||
expect(getStoragePathForItem(openedFolders, { ...makeFolder('2024'), columnIndex: 1 })).toBe(
|
||||
'avatars/2024'
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the bucket name so the value works with storage.from(bucket)', () => {
|
||||
const openedFolders = [makeFolder('avatars')]
|
||||
const path = getStoragePathForItem(openedFolders, { ...makeFile('a.png'), columnIndex: 1 })
|
||||
expect(path.startsWith('my-bucket')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStorageExplorerUrlForItem', () => {
|
||||
const projectRef = 'abcdef'
|
||||
const bucketId = 'my-bucket'
|
||||
|
||||
it('points a folder link at the folder itself', () => {
|
||||
const url = new URL(
|
||||
getStorageExplorerUrlForItem({
|
||||
openedFolders: [makeFolder('avatars')],
|
||||
item: { ...makeFolder('2024'), columnIndex: 1 },
|
||||
projectRef,
|
||||
bucketId,
|
||||
})
|
||||
)
|
||||
|
||||
expect(url.pathname).toContain(`/project/${projectRef}/storage/files/buckets/${bucketId}`)
|
||||
expect(url.searchParams.get('path')).toBe('avatars/2024')
|
||||
expect(url.searchParams.get('preview')).toBeNull()
|
||||
})
|
||||
|
||||
it('points a file link at its parent folder plus the file', () => {
|
||||
const url = new URL(
|
||||
getStorageExplorerUrlForItem({
|
||||
openedFolders: [makeFolder('avatars'), makeFolder('2024')],
|
||||
item: { ...makeFile('photo.png'), columnIndex: 2 },
|
||||
projectRef,
|
||||
bucketId,
|
||||
})
|
||||
)
|
||||
|
||||
expect(url.searchParams.get('path')).toBe('avatars/2024')
|
||||
expect(url.searchParams.get('preview')).toBe('photo.png')
|
||||
})
|
||||
|
||||
it('omits path at the bucket root', () => {
|
||||
const url = new URL(
|
||||
getStorageExplorerUrlForItem({
|
||||
openedFolders: [],
|
||||
item: { ...makeFile('photo.png'), columnIndex: 0 },
|
||||
projectRef,
|
||||
bucketId,
|
||||
})
|
||||
)
|
||||
|
||||
expect(url.searchParams.get('path')).toBeNull()
|
||||
expect(url.searchParams.get('preview')).toBe('photo.png')
|
||||
})
|
||||
|
||||
it('escapes a bucket id that needs encoding', () => {
|
||||
const url = new URL(
|
||||
getStorageExplorerUrlForItem({
|
||||
openedFolders: [],
|
||||
item: { ...makeFolder('a'), columnIndex: 0 },
|
||||
projectRef,
|
||||
bucketId: 'a b/c',
|
||||
})
|
||||
)
|
||||
|
||||
expect(url.pathname).toContain('a%20b%2Fc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clipboard helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(copyToClipboard).mockClear()
|
||||
vi.mocked(toast.success).mockClear()
|
||||
})
|
||||
|
||||
it('announces a copied relative path only once the write has landed', () => {
|
||||
copyStoragePath([makeFolder('images')], { ...makeFile('photo.png'), columnIndex: 1 })
|
||||
|
||||
const [text, onCopied] = vi.mocked(copyToClipboard).mock.calls[0]
|
||||
expect(text).toBe('images/photo.png')
|
||||
// The write is async and reports its own failure, so nothing is claimed up front
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
|
||||
onCopied?.()
|
||||
expect(toast.success).toHaveBeenCalledWith('Copied relative path for "photo.png"')
|
||||
})
|
||||
|
||||
it('announces a copied URL only once the write has landed', () => {
|
||||
copyStorageExplorerUrl({
|
||||
openedFolders: [],
|
||||
item: { ...makeFile('photo.png'), columnIndex: 0 },
|
||||
projectRef: 'abcdefghijklmnopqrst',
|
||||
bucketId: 'my-bucket',
|
||||
})
|
||||
|
||||
const [text, onCopied] = vi.mocked(copyToClipboard).mock.calls[0]
|
||||
expect(text).toContain('preview=photo.png')
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
|
||||
onCopied?.()
|
||||
expect(toast.success).toHaveBeenCalledWith('Copied URL for "photo.png"')
|
||||
})
|
||||
})
|
||||
+53
-6
@@ -5,6 +5,7 @@ import { inverseValidObjectKeyRegex, validObjectKeyRegex } from '../CreateBucket
|
||||
import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from '../Storage.constants'
|
||||
import { StorageItem, StorageItemMetadata } from '../Storage.types'
|
||||
import type { StorageObject } from '@/data/storage/bucket-objects-list-mutation'
|
||||
import { BASE_PATH } from '@/lib/constants'
|
||||
import type { StorageExplorerState } from '@/state/storage-explorer'
|
||||
|
||||
type UploadProgress = {
|
||||
@@ -130,14 +131,60 @@ export function sanitizeNameForDuplicateInColumn(
|
||||
return name
|
||||
}
|
||||
|
||||
export const copyPathToFolder = (
|
||||
openedFolders: StorageItem[],
|
||||
/** Bucket-relative path to an item */
|
||||
export function getStoragePathForItem(
|
||||
openedFolders: readonly StorageItem[],
|
||||
item: StorageItem & { columnIndex: number }
|
||||
): string {
|
||||
const folders = openedFolders.slice(0, item.columnIndex).map((folder) => folder.name)
|
||||
return folders.length > 0 ? `${folders.join('/')}/${item.name}` : item.name
|
||||
}
|
||||
|
||||
/** Absolute dashboard URL that reopens the item in the storage explorer */
|
||||
export function getStorageExplorerUrlForItem({
|
||||
openedFolders,
|
||||
item,
|
||||
projectRef,
|
||||
bucketId,
|
||||
}: {
|
||||
openedFolders: readonly StorageItem[]
|
||||
item: StorageItem & { columnIndex: number }
|
||||
projectRef: string
|
||||
bucketId: string
|
||||
}): string {
|
||||
const folders = openedFolders.slice(0, item.columnIndex).map((folder) => folder.name)
|
||||
const isFolder = item.type === STORAGE_ROW_TYPES.FOLDER
|
||||
|
||||
const url = new URL(
|
||||
`${BASE_PATH}/project/${projectRef}/storage/files/buckets/${encodeURIComponent(bucketId)}`,
|
||||
location.origin
|
||||
)
|
||||
const path = serializeStoragePath(isFolder ? [...folders, item.name] : folders)
|
||||
if (path.length > 0) url.searchParams.set('path', path)
|
||||
if (!isFolder) url.searchParams.set('preview', item.name)
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export const copyStoragePath = (
|
||||
openedFolders: readonly StorageItem[],
|
||||
item: StorageItem & { columnIndex: number }
|
||||
) => {
|
||||
const folders = openedFolders.slice(0, item.columnIndex).map((folder) => folder.name)
|
||||
const path = folders.length > 0 ? `${folders.join('/')}/${item.name}` : item.name
|
||||
copyToClipboard(path)
|
||||
toast.success(`Copied path to folder "${item.name}"`)
|
||||
// Toast from the callback: the write is async and reports its own failures.
|
||||
copyToClipboard(getStoragePathForItem(openedFolders, item), () =>
|
||||
toast.success(`Copied relative path for "${item.name}"`)
|
||||
)
|
||||
}
|
||||
|
||||
export const copyStorageExplorerUrl = (params: {
|
||||
openedFolders: readonly StorageItem[]
|
||||
item: StorageItem & { columnIndex: number }
|
||||
projectRef: string
|
||||
bucketId: string
|
||||
}) => {
|
||||
copyToClipboard(getStorageExplorerUrlForItem(params), () =>
|
||||
toast.success(`Copied URL for "${params.item.name}"`)
|
||||
)
|
||||
}
|
||||
|
||||
export const formatTime = (seconds: number) => {
|
||||
|
||||
Reference in new issue
Block a user