mirror of
https://github.com/supabase/supabase.git
synced 2026-10-16 06:39:09 +03:00
## Problem Closes #31221. The applications and shared UI packages still import the former `framer-motion` package instead of Motion's React entry point. ## Solution Replace runtime imports, type imports, and three test mocks with `motion/react` across the consuming workspaces. Replace the seven direct dependency declarations with `motion: ^11.18.2`. Preserve the existing animation engine version and all other dependency resolutions, including the asynchronous `domMax` feature loader. Historical blog examples remain as published; `framer-motion` remains a transitive implementation dependency of Motion. ## Review instructions 1. Run `pnpm install --frozen-lockfile` and check the seven dependency replacements in the lockfile. 2. Open the WWW Auth page and Docs homepage at desktop and mobile widths, with normal and reduced motion preferences. 3. Open the mobile navigation, expand Product on WWW, and close the menu using Escape and the close button. 4. Check the existing animation components, shared form validation, and Studio tests for unchanged behavior. Validation: - Studio: 731 test files, 8,334 passing tests and one expected failure, with `--maxWorkers=4`. - Shared UI, UI patterns, WWW, and UI library: 615 passing tests. - `pnpm run typecheck --concurrency=1`: all 16 repository tasks pass. - All six application source lint scripts and lint ratchets pass; generated Docs example copies are excluded from local lint. - Repository Prettier source check, lockfile formatting, and case hazard check pass. - WWW: 10 existing end-to-end checks across nine pages. - Docs: 5 existing guide link and accessibility checks across Database and Auth. - WWW and Docs mobile navigation pass at desktop/mobile sizes with normal/reduced motion; SQL-to-REST translation tabs exercise the asynchronous feature loader without browser errors. - Frozen-lockfile installation passes; all existing package and snapshot resolutions are unchanged. - Learn and UI library command palettes open, filter results, and close with Escape without browser errors. - Independent code review finds no actionable defects. - `npm run build -- --filter=www --filter=docs --filter=studio --filter=learn --filter=library --concurrency=1 --continue=always`: all five affected applications build successfully (9 tasks including prerequisites); asset uploads disabled for local validation. ## Checklist - [x] I have read [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) - [x] If I wrote a new docs topic or edited an existing topic, I used the `/write-the-docs` or `/edit-the-docs` skill, which applies the docs [style guide](https://github.com/supabase/supabase/tree/master/apps/docs/style-guide) (no docs topics changed) --------- Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
468 lines
17 KiB
TypeScript
468 lines
17 KiB
TypeScript
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
|
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { useDebounce } from '@uidotdev/usehooks'
|
|
import { useParams } from 'common'
|
|
import { compact, get, sum, uniqBy } from 'lodash'
|
|
import { Upload } from 'lucide-react'
|
|
import { AnimatePresence, motion } from 'motion/react'
|
|
import { DragEventHandler, useCallback, useMemo, useRef, useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
import { Checkbox, cn } from 'ui'
|
|
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
|
|
|
import {
|
|
STORAGE_ROW_STATUS,
|
|
STORAGE_ROW_TYPES,
|
|
STORAGE_SORT_BY,
|
|
STORAGE_SORT_BY_ORDER,
|
|
STORAGE_VIEWS,
|
|
} from '../Storage.constants'
|
|
import type { StorageItem } from '../Storage.types'
|
|
import { formatFolderItems } from '../StorageExplorer/StorageExplorer.utils'
|
|
import { useStoragePreference } from '../StorageExplorer/useStoragePreference'
|
|
import { uploadFilesToBucket } from './BucketFilePickerDialog.utils'
|
|
import { BucketFilePickerRow } from './BucketFilePickerRow'
|
|
import { useBucketFilePickerStateSnapshot } from './BucketFilePickerState'
|
|
import { InfiniteListDefault, LoaderForIconMenuItems } from '@/components/ui/InfiniteList'
|
|
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
|
|
import { bucketObjectsInfiniteQueryOptions } from '@/data/storage/bucket-objects-infinite-query'
|
|
import { storageKeys } from '@/data/storage/keys'
|
|
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
|
import { formatBytes } from '@/lib/helpers'
|
|
import { noop } from '@/lib/void'
|
|
|
|
const SelectAllCheckbox = ({
|
|
columnFiles,
|
|
selectedFilesFromColumn,
|
|
onChange,
|
|
}: {
|
|
columnFiles: StorageItem[]
|
|
selectedFilesFromColumn: StorageItem[]
|
|
onChange: () => void
|
|
}) => (
|
|
<Checkbox
|
|
className="-mt-0.5"
|
|
checked={columnFiles.length !== 0 && selectedFilesFromColumn.length === columnFiles.length}
|
|
disabled={columnFiles.length === 0}
|
|
onChange={onChange}
|
|
/>
|
|
)
|
|
|
|
const DragOverOverlay = ({
|
|
isOpen,
|
|
onDragLeave,
|
|
onDrop,
|
|
folderIsEmpty,
|
|
}: {
|
|
isOpen: boolean
|
|
onDragLeave: () => void
|
|
onDrop: () => void
|
|
folderIsEmpty: boolean
|
|
}) => {
|
|
return (
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.1, ease: 'easeOut' }}
|
|
className="h-full w-full absolute top-0"
|
|
>
|
|
<div
|
|
onDragLeave={onDragLeave}
|
|
onDrop={onDrop}
|
|
className="absolute top-0 flex h-full w-full items-center justify-center"
|
|
style={{ backgroundColor: folderIsEmpty ? 'rgba(0,0,0,0.1)' : 'rgba(0,0,0,0.2)' }}
|
|
>
|
|
{!folderIsEmpty && (
|
|
<div
|
|
className="w-3/4 h-32 border-2 border-dashed border-muted rounded-md flex flex-col items-center justify-center p-6 pointer-events-none"
|
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
|
|
>
|
|
<Upload className="text-white pointer-events-none" size={20} strokeWidth={2} />
|
|
<p className="text-center text-sm text-white mt-2 pointer-events-none">
|
|
Drop your files to upload to this folder
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
)
|
|
}
|
|
|
|
export interface BucketFilePickerColumnProps {
|
|
index: number
|
|
fullWidth?: boolean
|
|
}
|
|
|
|
export const BucketFilePickerColumn = ({
|
|
index,
|
|
fullWidth = false,
|
|
}: BucketFilePickerColumnProps) => {
|
|
const { ref: projectRef } = useParams()
|
|
const queryClient = useQueryClient()
|
|
|
|
const [isDraggedOver, setIsDraggedOver] = useState(false)
|
|
const columnRef = useRef<HTMLDivElement | null>(null)
|
|
|
|
const { hostEndpoint } = useProjectApiUrl({ projectRef: projectRef! })
|
|
const { can: canUpdateStorage } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
|
|
|
|
const {
|
|
columns,
|
|
itemSearchString,
|
|
bucket,
|
|
maxFiles,
|
|
acceptedFileExtensions,
|
|
pushColumnAtIndex,
|
|
selectedItems,
|
|
setSelectedItems,
|
|
clearSelectedItems,
|
|
selectedFilePreview,
|
|
setSelectedFilePreview,
|
|
popColumnAtIndex,
|
|
} = useBucketFilePickerStateSnapshot()
|
|
|
|
const isFileAccepted = (fileName: string) => {
|
|
if (!acceptedFileExtensions || acceptedFileExtensions.length === 0) return true
|
|
const ext = fileName.split('.').pop()?.toLowerCase() ?? ''
|
|
return acceptedFileExtensions.map((e) => e.replace(/^\./, '').toLowerCase()).includes(ext)
|
|
}
|
|
|
|
const path = columns.slice(0, index).join('/')
|
|
const selectedFolder = columns[index]
|
|
const setSelectedFolder = (folderName: string | null) => {
|
|
if (folderName) {
|
|
pushColumnAtIndex(folderName, index)
|
|
}
|
|
}
|
|
const isLastFolder = index === columns.length
|
|
|
|
const { view, sortBy, sortByOrder } = useStoragePreference(projectRef!)
|
|
// v2's sortBy.column doesn't support 'last_accessed_at'; the picker's own sort dropdown no
|
|
// longer offers it, but the preference is shared with the (still v1) main file explorer.
|
|
const sortColumn = sortBy === STORAGE_SORT_BY.LAST_ACCESSED_AT ? STORAGE_SORT_BY.NAME : sortBy
|
|
|
|
const debouncedSearchString = useDebounce(itemSearchString, 500)
|
|
const { data, isLoading, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
|
...bucketObjectsInfiniteQueryOptions({
|
|
projectRef,
|
|
bucketId: bucket.id,
|
|
path,
|
|
options: {
|
|
sortBy: {
|
|
column: sortColumn,
|
|
order: sortByOrder,
|
|
},
|
|
// When a user tries to search, only search in the last opened folder (rightmost column)
|
|
...(isLastFolder && debouncedSearchString ? { search: debouncedSearchString } : {}),
|
|
},
|
|
}),
|
|
})
|
|
|
|
const items = useMemo(() => {
|
|
const pages = data?.pages ?? []
|
|
// v2's `name` is the full key/prefix from the bucket root (folders end with a trailing
|
|
// slash), not the bare file/folder name — take the last path segment for display.
|
|
const objs = pages
|
|
.flatMap((page) => page.objects)
|
|
.map((object) => ({ ...object, name: object.name.split('/').pop() ?? object.name }))
|
|
// v2 returns folders and objects separately; re-combine them into the single sorted list
|
|
// formatFolderItems expects, tagging folders with `id: null` per its existing convention.
|
|
const folders = pages
|
|
.flatMap((page) => page.folders)
|
|
.map((folder) => {
|
|
const folderPath = folder.name.replace(/\/$/, '')
|
|
return {
|
|
id: null,
|
|
name: folderPath.split('/').pop() ?? folderPath,
|
|
created_at: null,
|
|
updated_at: null,
|
|
last_accessed_at: null,
|
|
metadata: null,
|
|
}
|
|
})
|
|
const direction = sortByOrder === STORAGE_SORT_BY_ORDER.DESC ? -1 : 1
|
|
const merged = [...folders, ...objs].sort((a, b) => {
|
|
const aValue = (sortColumn === STORAGE_SORT_BY.NAME ? a.name : a[sortColumn]) ?? ''
|
|
const bValue = (sortColumn === STORAGE_SORT_BY.NAME ? b.name : b[sortColumn]) ?? ''
|
|
return direction * String(aValue).localeCompare(String(bValue))
|
|
})
|
|
return formatFolderItems(merged)
|
|
}, [data, sortColumn, sortByOrder])
|
|
|
|
const haveSelectedItems = selectedItems.length > 0
|
|
const columnItemsId = items.map((item) => item.id)
|
|
const columnFiles = items.filter((item) => item.type === STORAGE_ROW_TYPES.FILE)
|
|
const selectedItemsFromColumn = selectedItems.filter((item) => columnItemsId.includes(item.id))
|
|
const selectedFilesFromColumn = selectedItemsFromColumn.filter(
|
|
(item) => item.type === STORAGE_ROW_TYPES.FILE
|
|
)
|
|
|
|
const columnItems = items.map((item) => ({ ...item, columnIndex: index }))
|
|
const columnItemsSize = sum(columnItems.map((item) => get(item, ['metadata', 'size'], 0)))
|
|
|
|
const isEmpty = items.filter((item) => item.status !== STORAGE_ROW_STATUS.LOADING).length === 0
|
|
|
|
const getItemKey = useCallback(
|
|
(index: number) => {
|
|
const item = columnItems[index]
|
|
return item?.id || `file-explorer-item-${index}`
|
|
},
|
|
[columnItems]
|
|
)
|
|
|
|
const itemProps = useMemo(
|
|
() => ({
|
|
view: view,
|
|
columnIndex: index,
|
|
selectedItems,
|
|
hideCheckbox: maxFiles === 1,
|
|
}),
|
|
[view, index, selectedItems, maxFiles]
|
|
)
|
|
|
|
const onSelectAllItemsInColumn = () => {
|
|
const columnFiles = columnItems.filter((item) => item.type === STORAGE_ROW_TYPES.FILE)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
const onSelectColumnEmptySpace = (columnIndex: number) => {
|
|
popColumnAtIndex(columnIndex)
|
|
setSelectedFilePreview(undefined)
|
|
clearSelectedItems()
|
|
}
|
|
|
|
const onDragOver: DragEventHandler<HTMLDivElement> = (event) => {
|
|
if (event) {
|
|
event.stopPropagation()
|
|
event.preventDefault()
|
|
if (event.type === 'dragover' && !isDraggedOver) {
|
|
setIsDraggedOver(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
const onDrop: DragEventHandler<HTMLDivElement> = async (event) => {
|
|
onDragOver(event)
|
|
|
|
if (!canUpdateStorage) {
|
|
toast('You need additional permissions to upload files to this project')
|
|
return
|
|
}
|
|
if (!hostEndpoint) {
|
|
toast.error('Unable to upload files at this time. Please try again.')
|
|
return
|
|
}
|
|
|
|
const files = Array.from(event.dataTransfer?.files ?? []) as File[]
|
|
await uploadFilesToBucket({
|
|
files,
|
|
projectRef: projectRef!,
|
|
hostEndpoint: hostEndpoint,
|
|
bucketName: bucket.name,
|
|
bucketId: bucket.id,
|
|
currentPath: columns.slice(0, index).join('/'),
|
|
queryClient,
|
|
})
|
|
|
|
queryClient.invalidateQueries({
|
|
queryKey: storageKeys.objects(projectRef!, bucket.id, columns.slice(0, index).join('/')),
|
|
})
|
|
|
|
setIsDraggedOver(false)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={columnRef}
|
|
className={cn(
|
|
fullWidth ? 'w-full' : 'w-64 border-r border-overlay',
|
|
view === STORAGE_VIEWS.LIST && 'h-full',
|
|
'hide-scrollbar relative flex shrink-0 flex-col overflow-auto'
|
|
)}
|
|
onDragOver={onDragOver}
|
|
onDrop={onDrop}
|
|
onClick={(event) => {
|
|
const eventTarget = get(event.target, ['className'], '')
|
|
if (typeof eventTarget === 'string' && eventTarget.includes('react-contexify')) return
|
|
onSelectColumnEmptySpace(index)
|
|
}}
|
|
>
|
|
{/* Checkbox selection for select all */}
|
|
{view === STORAGE_VIEWS.COLUMNS && maxFiles !== 1 && (
|
|
<div
|
|
className={cn(
|
|
'sticky top-0 z-10 mb-0 flex items-center bg-table-header-light px-2.5 in-data-[theme*=dark]:bg-table-header-dark',
|
|
haveSelectedItems ? 'h-10 py-3 opacity-100' : 'h-0 py-0 opacity-0',
|
|
'transition-all duration-200'
|
|
)}
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
{columnFiles.length > 0 ? (
|
|
<>
|
|
<SelectAllCheckbox
|
|
columnFiles={columnFiles}
|
|
selectedFilesFromColumn={selectedFilesFromColumn}
|
|
onChange={() => onSelectAllItemsInColumn()}
|
|
/>
|
|
<p className="text-sm text-foreground-light">
|
|
Select all {columnFiles.length} files
|
|
</p>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-foreground-light">No files available for selection</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* List Interface Header */}
|
|
{view === STORAGE_VIEWS.LIST && (
|
|
<div className="sticky top-0 py-2 z-10 flex min-w-min items-center border-b border-overlay bg-surface-100 px-2.5">
|
|
<div className="flex w-[40%] min-w-[250px] items-center">
|
|
{maxFiles !== 1 && (
|
|
<SelectAllCheckbox
|
|
columnFiles={columnFiles}
|
|
selectedFilesFromColumn={selectedFilesFromColumn}
|
|
onChange={() => onSelectAllItemsInColumn()}
|
|
/>
|
|
)}
|
|
<p className="text-sm">Name</p>
|
|
</div>
|
|
<p className="w-[11%] min-w-[100px] text-sm">Size</p>
|
|
<p className="w-[14%] min-w-[100px] text-sm">Type</p>
|
|
<p className="w-[15%] min-w-[160px] text-sm">Created at</p>
|
|
<p className="w-[15%] min-w-[160px] text-sm">Last modified at</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Shimmering loaders while fetching contents */}
|
|
{isLoading && (
|
|
<div
|
|
className={`
|
|
${fullWidth ? 'w-full' : 'w-64 border-r border-default'}
|
|
px-2 py-1 my-1 flex shrink-0 flex-col space-y-2 overflow-auto
|
|
`}
|
|
>
|
|
<ShimmeringLoader />
|
|
<ShimmeringLoader />
|
|
<ShimmeringLoader />
|
|
</div>
|
|
)}
|
|
|
|
{/* Column Interface */}
|
|
{columnItems.length > 0 && (
|
|
<InfiniteListDefault
|
|
className="h-full"
|
|
items={columnItems}
|
|
itemProps={itemProps}
|
|
getItemKey={getItemKey}
|
|
getItemSize={(index) => (index !== 0 && index === columnItems.length ? 85 : 37)}
|
|
// eslint-disable-next-line react/no-unstable-nested-components
|
|
ItemComponent={(props) => {
|
|
const item = props.item
|
|
const isPreviewed = !!(
|
|
selectedFilePreview?.id !== null && selectedFilePreview?.id === item.id
|
|
)
|
|
const isOpened =
|
|
selectedFolder !== null &&
|
|
item.type === STORAGE_ROW_TYPES.FOLDER &&
|
|
selectedFolder === item.name
|
|
|
|
return (
|
|
<BucketFilePickerRow
|
|
{...props}
|
|
onCheck={noop}
|
|
isPreviewed={isPreviewed}
|
|
isOpened={isOpened}
|
|
isSelected={!!props.selectedItems.find((i) => i.id === item.id)}
|
|
hideCheckbox={maxFiles === 1}
|
|
isDisabled={
|
|
item.type === STORAGE_ROW_TYPES.FILE && !isFileAccepted(item.name ?? '')
|
|
}
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
event.preventDefault()
|
|
if (item.status !== STORAGE_ROW_STATUS.LOADING && !isOpened && !isPreviewed) {
|
|
if (item.type === STORAGE_ROW_TYPES.FOLDER) {
|
|
setSelectedFilePreview(undefined)
|
|
setSelectedFolder(item.name)
|
|
} else {
|
|
setSelectedFilePreview(item)
|
|
// deselect all folders when previewing a file
|
|
popColumnAtIndex(index)
|
|
clearSelectedItems()
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
)
|
|
}}
|
|
LoaderComponent={LoaderForIconMenuItems}
|
|
hasNextPage={hasNextPage}
|
|
isLoadingNextPage={isFetching}
|
|
onLoadNextPage={fetchNextPage}
|
|
/>
|
|
)}
|
|
|
|
{debouncedSearchString.length > 0 && isEmpty && !isLoading && (
|
|
<div className="h-full w-full flex flex-col items-center justify-center">
|
|
<p className="text-sm my-3 text-foreground">No results found in this folder</p>
|
|
<p className="w-40 text-center text-sm text-foreground-light">
|
|
Your search for "{debouncedSearchString}" did not return any results
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{debouncedSearchString.length === 0 && isEmpty && !isLoading && (
|
|
<div className="h-full w-full flex flex-col items-center justify-center">
|
|
<p className="text-sm my-3 opacity-75">Drop your files here</p>
|
|
<p className="w-40 text-center text-xs text-foreground-light">
|
|
Or upload them via the "Upload files" button above
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<DragOverOverlay
|
|
isOpen={isDraggedOver}
|
|
folderIsEmpty={isEmpty}
|
|
onDragLeave={() => setIsDraggedOver(false)}
|
|
onDrop={() => setIsDraggedOver(false)}
|
|
/>
|
|
|
|
{/* List interface footer */}
|
|
{view === STORAGE_VIEWS.LIST && (
|
|
<div className="shrink-0 rounded-b-md z-10 flex min-w-min items-center bg-panel-footer-light px-2.5 py-2 in-data-[theme*=dark]:bg-panel-footer-dark w-full">
|
|
<p className="text-sm">
|
|
{formatBytes(columnItemsSize)} for {columnItems.length} items
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{selectedFolder ? (
|
|
<BucketFilePickerColumn key={`column-${index + 1}`} index={index + 1} />
|
|
) : null}
|
|
</>
|
|
)
|
|
}
|