diff --git a/apps/studio/components/interfaces/ErrorHandling/error-mappings.tsx b/apps/studio/components/interfaces/ErrorHandling/error-mappings.tsx index c4095cae060..b5ac4ecf9a6 100644 --- a/apps/studio/components/interfaces/ErrorHandling/error-mappings.tsx +++ b/apps/studio/components/interfaces/ErrorHandling/error-mappings.tsx @@ -1,6 +1,7 @@ import { ComponentType } from 'react' import { ConnectionTimeoutError } from 'types/api-errors' import type { ClassifiedError, KnownErrorType } from 'types/api-errors' +import type { ResponseError } from 'types/base' import { ConnectionTimeoutTroubleshooting } from './errorMappings/ConnectionTimeout' @@ -9,7 +10,9 @@ export interface ErrorMapping { Troubleshooting: ComponentType } -type ErrorConstructor = new (...args: any[]) => ClassifiedError +type ErrorConstructor = new ( + ...args: ConstructorParameters +) => ClassifiedError export const ERROR_MAPPINGS = new Map([ [ diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.test.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.test.tsx new file mode 100644 index 00000000000..3f3fb85d7b1 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.test.tsx @@ -0,0 +1,217 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { FileExplorerHeader } from './FileExplorerHeader' +import { STORAGE_SORT_BY, STORAGE_SORT_BY_ORDER, STORAGE_VIEWS } from '../Storage.constants' +import { customRender as render } from 'tests/lib/custom-render' + +const { + mockTrack, + mockUseStorageExplorerStateSnapshot, + mockUseAsyncCheckPermissions, + mockIsAPIDocsSidePanelEnabled, +} = vi.hoisted(() => ({ + mockTrack: vi.fn(), + mockUseStorageExplorerStateSnapshot: vi.fn(), + mockUseAsyncCheckPermissions: vi.fn(), + mockIsAPIDocsSidePanelEnabled: vi.fn(), +})) + +vi.mock('lib/telemetry/track', () => ({ useTrack: () => mockTrack })) +vi.mock('state/storage-explorer', () => ({ + useStorageExplorerStateSnapshot: () => mockUseStorageExplorerStateSnapshot(), +})) +vi.mock('hooks/misc/useCheckPermissions', () => ({ + useAsyncCheckPermissions: () => mockUseAsyncCheckPermissions(), +})) +vi.mock('components/interfaces/App/FeaturePreview/FeaturePreviewContext', () => ({ + useIsAPIDocsSidePanelEnabled: () => mockIsAPIDocsSidePanelEnabled(), +})) +vi.mock('components/ui/APIDocsButton', () => ({ + APIDocsButton: () => null, +})) + +function makeColumn(name: string) { + return { + id: name, + name, + status: 'READY', + items: [], + } +} + +function createSnapshot(view: STORAGE_VIEWS = STORAGE_VIEWS.COLUMNS) { + return { + columns: [makeColumn('my-bucket'), makeColumn('images'), makeColumn('2024')], + sortBy: STORAGE_SORT_BY.NAME, + setSortBy: vi.fn(), + sortByOrder: STORAGE_SORT_BY_ORDER.ASC, + setSortByOrder: vi.fn(), + popColumn: vi.fn(), + popColumnAtIndex: vi.fn(), + popOpenedFolders: vi.fn(), + popOpenedFoldersAtIndex: vi.fn(), + fetchFoldersByPath: vi.fn().mockResolvedValue(undefined), + refetchAllOpenedFolders: vi.fn().mockResolvedValue(undefined), + addNewFolderPlaceholder: vi.fn(), + clearOpenedFolders: vi.fn(), + setSelectedFilePreview: vi.fn(), + selectedBucket: { id: 'bucket-id', name: 'my-bucket' }, + isSearching: false, + setIsSearching: vi.fn(), + view, + setView: vi.fn(), + } +} + +describe('FileExplorerHeader', () => { + beforeEach(() => { + mockTrack.mockReset() + mockUseStorageExplorerStateSnapshot.mockReset() + mockUseAsyncCheckPermissions.mockReset() + mockIsAPIDocsSidePanelEnabled.mockReset() + + mockUseStorageExplorerStateSnapshot.mockReturnValue(createSnapshot()) + mockUseAsyncCheckPermissions.mockReturnValue({ can: true }) + mockIsAPIDocsSidePanelEnabled.mockReturnValue(false) + }) + + it('renders full breadcrumbs in column view and places Navigate before Reload', () => { + render( + + ) + + const rootBreadcrumb = screen.getByRole('button', { name: 'my-bucket' }) + const inactiveBreadcrumb = screen.getByRole('button', { name: 'images' }) + const activeBreadcrumb = screen.getByText('2024') + + expect(rootBreadcrumb).toBeInTheDocument() + expect(inactiveBreadcrumb).toBeInTheDocument() + expect(activeBreadcrumb).toBeInTheDocument() + expect(inactiveBreadcrumb).toHaveClass('text-foreground-lighter') + expect(activeBreadcrumb).toHaveClass('text-foreground') + expect(activeBreadcrumb).not.toHaveClass('text-foreground-lighter') + expect(screen.queryByRole('button', { name: '2024' })).not.toBeInTheDocument() + + const navigateButton = screen.getByRole('button', { name: 'Navigate' }) + const reloadButton = screen.getByRole('button', { name: 'Reload' }) + + expect( + navigateButton.compareDocumentPosition(reloadButton) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + }) + + it('uses breadcrumb buttons to navigate back to a previous folder level with keyboard input', async () => { + const snapshot = createSnapshot() + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + render( + + ) + + const breadcrumbButton = screen.getByRole('button', { name: 'images' }) + breadcrumbButton.focus() + await userEvent.keyboard('{Enter}') + + expect(snapshot.popColumnAtIndex).toHaveBeenCalledWith(1) + expect(snapshot.popOpenedFoldersAtIndex).toHaveBeenCalledWith(0) + }) + + it('opens path edit mode from Navigate and tracks the click', async () => { + render( + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Navigate' })) + + expect(mockTrack).toHaveBeenCalledWith('storage_explorer_navigate_clicked') + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Navigate to folder')).toBeInTheDocument() + expect(screen.getByDisplayValue('images/2024')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Navigate' })).toBeInTheDocument() + }) + + it('submits a path, tracks the submission, and calls the existing path navigation flow', async () => { + const snapshot = createSnapshot() + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + render( + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Navigate' })) + + const input = screen.getByDisplayValue('images/2024') + await userEvent.clear(input) + await userEvent.type(input, 'archive/2025') + await userEvent.click(screen.getByRole('button', { name: 'Navigate' })) + + await waitFor(() => { + expect(snapshot.fetchFoldersByPath).toHaveBeenCalledWith({ paths: ['archive', '2025'] }) + }) + await waitFor(() => { + expect(mockTrack).toHaveBeenCalledWith('storage_explorer_navigate_submitted') + }) + + expect(mockTrack).toHaveBeenCalledWith('storage_explorer_navigate_clicked') + }) + + it('navigates to bucket root without tracking a folder-path submission', async () => { + const snapshot = createSnapshot() + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + render( + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Navigate' })) + + const input = screen.getByDisplayValue('images/2024') + await userEvent.clear(input) + await userEvent.click(screen.getByRole('button', { name: 'Navigate' })) + + await waitFor(() => { + expect(snapshot.popColumnAtIndex).toHaveBeenCalledWith(0) + }) + + expect(snapshot.clearOpenedFolders).toHaveBeenCalled() + expect(snapshot.setSelectedFilePreview).toHaveBeenCalledWith(undefined) + expect(mockTrack).toHaveBeenCalledWith('storage_explorer_navigate_clicked') + expect(mockTrack).not.toHaveBeenCalledWith('storage_explorer_navigate_submitted') + }) + + it('does not render Navigate in list view', () => { + mockUseStorageExplorerStateSnapshot.mockReturnValue(createSnapshot(STORAGE_VIEWS.LIST)) + + render( + + ) + + expect(screen.queryByRole('button', { name: 'Navigate' })).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx index f8d5cce061b..d16d59c1921 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx @@ -3,10 +3,11 @@ import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeatureP import { APIDocsButton } from 'components/ui/APIDocsButton' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useTrack } from 'lib/telemetry/track' import { compact, isEqual, noop } from 'lodash' import { + ArrowLeft, Check, - ChevronLeft, ChevronRight, Columns, Edit2, @@ -18,11 +19,25 @@ import { Upload, X, } from 'lucide-react' -import { useEffect, useRef, useState } from 'react' +import { + type ChangeEvent, + type ChangeEventHandler, + type SyntheticEvent, + useEffect, + useRef, + useState, +} from 'react' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' import { Button, cn, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogSection, + DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -31,8 +46,10 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, - Input, + FieldDescription_Shadcn_, + Label_Shadcn_, } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' import { STORAGE_SORT_BY, STORAGE_SORT_BY_ORDER, STORAGE_VIEWS } from '../Storage.constants' @@ -53,41 +70,72 @@ const SORT_ORDER_OPTIONS = [ { key: STORAGE_SORT_BY_ORDER.DESC, name: 'Descending' }, ] -const HeaderPathEdit = ({ loading, isSearching, breadcrumbs, togglePathEdit }: any) => { +interface NavigateDialogProps { + open: boolean + pathString: string + onOpenChange: (open: boolean) => void + onPathStringChange: ChangeEventHandler + onCancel: () => void + onSubmit: (event?: SyntheticEvent) => void +} + +const NavigateDialog = ({ + open, + pathString, + onOpenChange, + onPathStringChange, + onCancel, + onSubmit, +}: NavigateDialogProps) => { + const inputId = 'storage-explorer-navigate-path' + const descriptionId = 'storage-explorer-navigate-path-description' + return ( -
(!loading.isLoading ? togglePathEdit() : {})} - > - {loading.isLoading ? ( -
- -

{loading.message}

-
- ) : ( -
- {breadcrumbs.length > 1 && ( -

{breadcrumbs[breadcrumbs.length - 1] || ''}

- )} - {!isSearching && ( - - )} -
- )} -
+ + + + Navigate to folder + + Enter a folder path within this bucket. + + + + Path + { + if (event.key === 'Enter') onSubmit(event) + }} + /> + + Enter a folder path within this bucket. + + + + + + + + ) } const HeaderBreadcrumbs = ({ loading, - isSearching, breadcrumbs, selectBreadcrumb, }: { loading: { isLoading: boolean; message: string } - isSearching: boolean breadcrumbs: string[] selectBreadcrumb: (i: number) => void }) => { @@ -117,20 +165,32 @@ const HeaderBreadcrumbs = ({

{loading.message}

) : ( -
- {formattedBreadcrumbs.map((crumb, idx: number) => ( -
- {idx !== 0 && } -

(crumb.name !== ellipsis ? selectBreadcrumb(crumb.index) : {})} - > - {crumb.name} -

-
- ))} +
+ {formattedBreadcrumbs.map((crumb, idx: number) => { + const isEllipsis = crumb.name === ellipsis + const isActive = crumb.index === breadcrumbs.length - 1 + + return ( +
+ {idx !== 0 && ( + + )} + {isEllipsis ? ( + {crumb.name} + ) : isActive ? ( + {crumb.name} + ) : ( + + )} +
+ ) + })}
) } @@ -138,7 +198,7 @@ const HeaderBreadcrumbs = ({ interface FileExplorerHeader { itemSearchString: string setItemSearchString: (value: string) => void - onFilesUpload: (event: any, columnIndex?: number) => void + onFilesUpload: (event: ChangeEvent, columnIndex?: number) => void } export const FileExplorerHeader = ({ @@ -148,15 +208,16 @@ export const FileExplorerHeader = ({ }: FileExplorerHeader) => { const snap = useStorageExplorerStateSnapshot() const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled() + const track = useTrack() const [pathString, setPathString] = useState('') const [loading, setLoading] = useState({ isLoading: false, message: '' }) - const [isEditingPath, setIsEditingPath] = useState(false) + const [isPathDialogOpen, setIsPathDialogOpen] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) - const uploadButtonRef: any = useRef(null) - const previousBreadcrumbs: any = useRef(null) + const uploadButtonRef = useRef(null) + const previousBreadcrumbs = useRef(null) const { columns, @@ -184,7 +245,7 @@ export const FileExplorerHeader = ({ // [Joshen] Somehow toggle search triggers this despite breadcrumbs // being unchanged. Manually doing a prop check to fix this if (!isEqual(previousBreadcrumbs.current, breadcrumbs)) { - setIsEditingPath(false) + setIsPathDialogOpen(false) previousBreadcrumbs.current = breadcrumbs } }, [breadcrumbs]) @@ -203,22 +264,28 @@ export const FileExplorerHeader = ({ /** Methods for path editings */ const togglePathEdit = () => { - setIsEditingPath(true) + setIsPathDialogOpen(true) setPathString(breadcrumbs.slice(1).join('/')) if (snap.isSearching) onCancelSearch() } - const onUpdatePathString = (event: any) => { + const onUpdatePathString = (event: ChangeEvent) => { setPathString(event.target.value) } - const navigateByPathString = (event: any) => { + const navigateByPathString = async (event?: SyntheticEvent) => { if (event) { event.preventDefault() event.stopPropagation() } - setIsEditingPath(false) - onSetPathByString(compact(pathString.split('/'))) + + const paths = compact(pathString.split('/')) + setIsPathDialogOpen(false) + await onSetPathByString(paths) + + if (paths.length > 0) { + track('storage_explorer_navigate_submitted') + } } const onSetPathByString = async (paths: string[]) => { @@ -235,14 +302,14 @@ export const FileExplorerHeader = ({ } const cancelSetPathString = () => { - setIsEditingPath(false) + setIsPathDialogOpen(false) } /** Methods for searching */ // Search is currently within local scope when the view is set to list // Searching for column view requires much more thinking const toggleSearch = () => { - setIsEditingPath(false) + setIsPathDialogOpen(false) snap.setIsSearching(true) } @@ -264,232 +331,223 @@ export const FileExplorerHeader = ({ setIsRefreshing(false) } + const onOpenNavigate = () => { + track('storage_explorer_navigate_clicked') + togglePathEdit() + } + return ( -
- {/* Navigation */} -
- {breadcrumbs.length > 1 && ( - , + icon={} + size="tiny" + type="text" + className="shrink-0 px-1" + disabled={backDisabled} + onClick={() => { + setIsPathDialogOpen(false) + onSelectBack() + }} + /> +
+ + )} + {breadcrumbs.length > 1 ? ( + + ) : null} +
+ + {/* Actions */} +
+
+ {snap.view === STORAGE_VIEWS.COLUMNS && ( , - ]} - /> - - ) : snap.view === STORAGE_VIEWS.COLUMNS ? ( - - ) : breadcrumbs.length > 1 ? ( - - ) : null} -
- - {/* Actions */} -
-
- - - - + Navigate + + )} - - - {VIEW_OPTIONS.map((option) => ( - snap.setView(option.key)}> -
-

{option.name}

- {snap.view === option.key && ( - - )} -
-
- ))} - - - Sort by - - {SORT_BY_OPTIONS.map((option) => ( - setSortBy(option.key)}> -
-

{option.name}

- {sortBy === option.key && ( - - )} -
-
- ))} -
-
- - Sort order - - {SORT_ORDER_OPTIONS.map((option) => ( - setSortByOrder(option.key)}> + + + + + + + {VIEW_OPTIONS.map((option) => ( + snap.setView(option.key)}>

{option.name}

- {sortByOrder === option.key && ( + {snap.view === option.key && ( )}
))} -
-
-
-
-
+ + + Sort by + + {SORT_BY_OPTIONS.map((option) => ( + setSortBy(option.key)}> +
+

{option.name}

+ {sortBy === option.key && ( + + )} +
+
+ ))} +
+
+ + Sort order + + {SORT_ORDER_OPTIONS.map((option) => ( + setSortByOrder(option.key)} + > +
+

{option.name}

+ {sortByOrder === option.key && ( + + )} +
+
+ ))} +
+
+ + +
-
-
-
- -
- } - type="text" - disabled={!canUpdateStorage || breadcrumbs.length === 0} - onClick={onSelectUpload} - tooltip={{ - content: { - side: 'bottom', - text: !canUpdateStorage - ? 'You need additional permissions to upload files' - : undefined, - }, - }} - > - Upload files - - } - type="text" - disabled={!canUpdateStorage || breadcrumbs.length === 0} - onClick={() => addNewFolderPlaceholder(-1)} - tooltip={{ - content: { - side: 'bottom', - text: !canUpdateStorage - ? 'You need additional permissions to create folders' - : undefined, - }, - }} - > - Create folder - -
+
+
+
+ +
+ } + type="text" + disabled={!canUpdateStorage || breadcrumbs.length === 0} + onClick={onSelectUpload} + tooltip={{ + content: { + side: 'bottom', + text: !canUpdateStorage + ? 'You need additional permissions to upload files' + : undefined, + }, + }} + > + Upload files + + } + type="text" + disabled={!canUpdateStorage || breadcrumbs.length === 0} + onClick={() => addNewFolderPlaceholder(-1)} + tooltip={{ + content: { + side: 'bottom', + text: !canUpdateStorage + ? 'You need additional permissions to create folders' + : undefined, + }, + }} + > + Create folder + +
-
-
- {snap.isSearching ? ( - } - actions={[ +
+
+ {snap.isSearching ? ( + } + actions={[ +
- - {isNewAPIDocsEnabled && ( - <> -
-
- + className="px-1" + onClick={toggleSearch} + /> + )}
- - )} + + {isNewAPIDocsEnabled && ( + <> +
+
+ +
+ + )} +
+
+ +
) } diff --git a/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx b/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx index fdacc798f6e..1cb604fcfd5 100644 --- a/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx +++ b/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx @@ -84,6 +84,7 @@ const BucketPage: NextPageWithLayout = () => { }, { label: 'Buckets', + href: `/project/${ref}/storage/files`, }, ]} primaryActions={ diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 367fd1cd9f6..b42a401311d 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -3012,6 +3012,30 @@ export interface LogExplorerQueryRunButtonClickedEvent { groups: TelemetryGroups } +/** + * User clicked the Navigate action in the storage explorer header. + * + * @group Events + * @source studio + * @page /project/{ref}/storage/files/buckets/{bucketId} + */ +export interface StorageExplorerNavigateClickedEvent { + action: 'storage_explorer_navigate_clicked' + groups: TelemetryGroups +} + +/** + * User submitted a folder path from the storage explorer Navigate action. + * + * @group Events + * @source studio + * @page /project/{ref}/storage/files/buckets/{bucketId} + */ +export interface StorageExplorerNavigateSubmittedEvent { + action: 'storage_explorer_navigate_submitted' + groups: TelemetryGroups +} + /** * @hidden */ @@ -3085,6 +3109,8 @@ export type TelemetryEvent = | SendFeedbackButtonClickedEvent | SqlEditorQueryRunButtonClickedEvent | LogExplorerQueryRunButtonClickedEvent + | StorageExplorerNavigateClickedEvent + | StorageExplorerNavigateSubmittedEvent | StudioPricingPlanCtaClickedEvent | StudioPricingSidePanelOpenedEvent | ReportsDatabaseGrafanaBannerClickedEvent