diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx index ebbaee5a516..0434db1514a 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx @@ -2,11 +2,11 @@ import { Transition } from '@headlessui/react' import { PermissionAction } from '@supabase/shared-types/out/constants' import { get, noop, sum } from 'lodash' import { Upload } from 'lucide-react' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useContextMenu } from 'react-contexify' import { toast } from 'sonner' -import InfiniteList from 'components/ui/InfiniteList' +import { InfiniteListDefault, LoaderForIconMenuItems } from 'components/ui/InfiniteList' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { BASE_PATH } from 'lib/constants' @@ -146,6 +146,23 @@ export const FileExplorerColumn = ({ /> ) + const getItemKey = useCallback( + (index: number) => { + const item = columnItems[index] + return item?.id || `file-explorer-item-${index}` + }, + [columnItems] + ) + + const itemProps = useMemo( + () => ({ + view: snap.view, + columnIndex: index, + selectedItems, + }), + [snap.view, index, selectedItems] + ) + return (
(index !== 0 && index === columnItems.length ? 85 : 37)} - hasNextPage={column.status !== STORAGE_ROW_STATUS.LOADING && column.hasMoreItems} - isLoadingNextPage={column.isLoadingMoreItems} - onLoadNextPage={() => onColumnLoadMore(index, column)} - /> + {columnItems.length > 0 && ( + (index !== 0 && index === columnItems.length ? 85 : 37)} + ItemComponent={FileExplorerRow} + LoaderComponent={LoaderForIconMenuItems} + hasNextPage={column.status !== STORAGE_ROW_STATUS.LOADING && column.hasMoreItems} + isLoadingNextPage={column.isLoadingMoreItems} + onLoadNextPage={() => onColumnLoadMore(index, column)} + /> + )} {/* Drag drop upload CTA for when column is empty */} {!(snap.isSearching && itemSearchString.length > 0) && diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx index 4634275532a..2db50f2dc88 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx @@ -18,10 +18,10 @@ import { useContextMenu } from 'react-contexify' import SVG from 'react-inlinesvg' import { useParams } from 'common' -import type { ItemRenderer } from 'components/ui/InfiniteList' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { BASE_PATH } from 'lib/constants' import { formatBytes } from 'lib/helpers' +import type { CSSProperties } from 'react' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' import { Checkbox, @@ -46,7 +46,7 @@ import { STORAGE_VIEWS, URL_EXPIRY_DURATION, } from '../Storage.constants' -import { StorageItem, StorageItemWithColumn } from '../Storage.types' +import { StorageItemWithColumn, type StorageItem } from '../Storage.types' import { FileExplorerRowEditing } from './FileExplorerRowEditing' import { copyPathToFolder, downloadFile } from './StorageExplorer.utils' import { useCopyUrl } from './useCopyUrl' @@ -99,18 +99,22 @@ export const RowIcon = ({ } interface FileExplorerRowProps { + index: number + item: StorageItem view: STORAGE_VIEWS columnIndex: number selectedItems: StorageItemWithColumn[] + style?: CSSProperties } -export const FileExplorerRow: ItemRenderer = ({ +export const FileExplorerRow = ({ index: itemIndex, item, view = STORAGE_VIEWS.COLUMNS, columnIndex = 0, selectedItems = [], -}) => { + style, +}: FileExplorerRowProps) => { const { ref: projectRef, bucketId } = useParams() const { @@ -141,7 +145,7 @@ export const FileExplorerRow: ItemRenderer = const isPreviewed = !isEmpty(selectedFilePreview) && isEqual(selectedFilePreview?.id, item.id) const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') - const onSelectFile = async (columnIndex: number, file: StorageItem) => { + const onSelectFile = async (columnIndex: number) => { popColumnAtIndex(columnIndex) popOpenedFoldersAtIndex(columnIndex - 1) setSelectedFilePreview(itemWithColumnIndex) @@ -299,11 +303,14 @@ export const FileExplorerRow: ItemRenderer = : '100%' if (item.status === STORAGE_ROW_STATUS.EDITING) { - return + return ( + + ) } return (
{ event.stopPropagation() @@ -326,7 +333,7 @@ export const FileExplorerRow: ItemRenderer = if (item.status !== STORAGE_ROW_STATUS.LOADING && !isOpened && !isPreviewed) { item.type === STORAGE_ROW_TYPES.FOLDER || item.type === STORAGE_ROW_TYPES.BUCKET ? openFolder(columnIndex, item) - : onSelectFile(columnIndex, item) + : onSelectFile(columnIndex) } }} > diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx index 2ee1a7f02c3..9c666b337e7 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx @@ -1,5 +1,5 @@ import { has } from 'lodash' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, type CSSProperties } from 'react' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES, STORAGE_VIEWS } from '../Storage.constants' @@ -10,12 +10,14 @@ export interface FileExplorerRowEditingProps { item: StorageItem view: STORAGE_VIEWS columnIndex: number + style?: CSSProperties } export const FileExplorerRowEditing = ({ item, view, columnIndex, + style, }: FileExplorerRowEditingProps) => { const { renameFile, renameFolder, addNewFolder, updateRowStatus } = useStorageExplorerStateSnapshot() @@ -86,7 +88,10 @@ export const FileExplorerRowEditing = ({ }, []) return ( -
+
) => { - const bucket = data.buckets[index] - const isSelected = data.selectedBucketId === bucket.id + ({ item, projectRef, selectedBucketId, style }: VirtualizedBucketRowProps) => { + const isSelected = selectedBucketId === item.id return ( ) - }, - (prev, next) => { - if (!areEqual(prev, next)) return false - - const prevBucket = prev.data.buckets[prev.index] - const nextBucket = next.data.buckets[next.index] - - if (prevBucket !== nextBucket) return false - - const wasSelected = prev.data.selectedBucketId === prevBucket.id - const isSelected = next.data.selectedBucketId === nextBucket.id - - return wasSelected === isSelected } ) VirtualizedBucketRow.displayName = 'VirtualizedBucketRow' const BucketListVirtualized = ({ buckets, selectedBucketId, projectRef = '' }: BucketListProps) => { - const [listHeight, setListHeight] = useState(500) - const sizerRef = useRef(null) - - useLayoutEffect(() => { - if (sizerRef.current) { - const resizeObserver = new ResizeObserver(([entry]) => { - const { height } = entry.contentRect - setListHeight(height) - }) - - resizeObserver.observe(sizerRef.current) - setListHeight(sizerRef.current.getBoundingClientRect().height) - - return () => { - resizeObserver.disconnect() - } - } - }, []) - - const itemData = useMemo( + const itemData = useMemo( () => ({ - buckets, projectRef, selectedBucketId, }), - [buckets, projectRef, selectedBucketId] + [projectRef, selectedBucketId] + ) + + const getItemKey = useCallback( + (index: number) => { + const item = buckets[index] + return item?.id || `bucket-${index}` + }, + [buckets] ) return ( -
- buckets[index].id} - height={listHeight} - // itemSize should match the height of BucketRow + any gap/margin - itemSize={28} - width="100%" - > - {VirtualizedBucketRow} - -
+ 28} + ItemComponent={VirtualizedBucketRow} + // There is no loader because all buckets load from backend at once + LoaderComponent={() => null} + /> ) } +type BucketListProps = { + buckets: Bucket[] + selectedBucketId?: string + projectRef?: string +} + export const BucketList = ({ buckets, selectedBucketId, projectRef = '' }: BucketListProps) => { const numBuckets = buckets.length diff --git a/apps/studio/components/interfaces/Storage/StorageMenu.tsx b/apps/studio/components/interfaces/Storage/StorageMenu.tsx index 0ee63944eb8..7b0c795f94d 100644 --- a/apps/studio/components/interfaces/Storage/StorageMenu.tsx +++ b/apps/studio/components/interfaces/Storage/StorageMenu.tsx @@ -74,7 +74,11 @@ export const StorageMenu = () => { return ( <> - +
@@ -107,12 +111,12 @@ export const StorageMenu = () => {
-
+
0 ? 'mb-3' : 'mb-5' diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx index a1779c5aa5c..574b9aa4721 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx @@ -1,37 +1,36 @@ import dayjs from 'dayjs' import { Archive, ArchiveRestoreIcon, ExternalLink } from 'lucide-react' import Link from 'next/link' -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' import { useInView } from 'react-intersection-observer' import { Button, cn } from 'ui' import { Markdown } from 'components/interfaces/Markdown' import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import type { ItemRenderer } from 'components/ui/InfiniteList' -import { Notification, NotificationData } from 'data/notifications/notifications-v2-query' +import { useVirtualizerContext } from 'components/ui/InfiniteList' +import { NotificationData, type NotificationsData } from 'data/notifications/notifications-v2-query' import { useProjectDetailQuery } from 'data/projects/project-detail-query' import type { Organization } from 'types' import { CriticalIcon, WarningIcon } from 'ui' interface NotificationRowProps { - setRowHeight: (idx: number, height: number) => void + index: number + item: NotificationsData[number] getOrganizationById: (id: number) => Organization getOrganizationBySlug: (slug: string) => Organization onUpdateNotificationStatus: (id: string, status: 'archived' | 'seen') => void queueMarkRead: (id: string) => void } -const NotificationRow: ItemRenderer = ({ +const NotificationRow = ({ index, - listRef, item: notification, - setRowHeight, getOrganizationById, getOrganizationBySlug, onUpdateNotificationStatus, queueMarkRead, -}) => { - const ref = useRef(null) +}: NotificationRowProps) => { + const { virtualizer } = useVirtualizerContext() const { ref: viewRef, inView } = useInView() const { status, priority } = notification @@ -55,13 +54,6 @@ const NotificationRow: ItemRenderer = ({ console.log('Action', type) } - useEffect(() => { - if (ref.current) { - listRef?.current?.resetAfterIndex(0) - setRowHeight(index, ref.current.clientHeight) - } - }, [ref]) - useEffect(() => { if (inView && notification.status === 'new') { queueMarkRead(notification.id) @@ -70,7 +62,7 @@ const NotificationRow: ItemRenderer = ({ return (
{ // Storing in ref as no re-rendering required const markedRead = useRef([]) - // [Joshen] Just FYI this variable row heights logic should ideally live in InfiniteList - // but I ran into some infinite loops issues when I was trying to implement it there - // so opting to simplify and implement it here for now - const rowHeights = useRef<{ [key: number]: number }>({}) - const { data: organizations } = useOrganizationsQuery({ enabled: open }) const { data, @@ -197,20 +192,16 @@ export const NotificationsPopoverV2 = () => {
{notifications.length > 0 && !(activeTab === 'archived' && snap.filterStatuses.includes('unread')) ? ( - + LoaderComponent={({ style }) => ( +
- } + )} itemProps={{ - setRowHeight: (idx: number, height: number) => { - if (rowHeights.current) { - rowHeights.current = { ...rowHeights.current, [idx]: height } - } - }, getOrganizationById: (id: number) => organizations?.find((org) => org.id === id)!, getOrganizationBySlug: (slug: string) => @@ -224,7 +215,7 @@ export const NotificationsPopoverV2 = () => { } }, }} - getItemSize={(idx: number) => rowHeights?.current?.[idx] ?? 56} + getItemSize={() => 56} hasNextPage={hasNextPage} isLoadingNextPage={isFetchingNextPage} onLoadNextPage={() => fetchNextPage()} diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index c7db3f26739..06fc58a7840 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -15,7 +15,6 @@ import { getEntityLintDetails, } from 'components/interfaces/TableGridEditor/TableEntity.utils' import { EntityTypeIcon } from 'components/ui/EntityTypeIcon' -import type { ItemRenderer } from 'components/ui/InfiniteList' import { InlineLink } from 'components/ui/InlineLink' import { getTableDefinition } from 'data/database/table-definition-query' import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' @@ -28,6 +27,7 @@ import { fetchAllTableRows } from 'data/table-rows/table-rows-query' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { formatSql } from 'lib/formatSql' +import type { CSSProperties } from 'react' import { useTableEditorStateSnapshot } from 'state/table-editor' import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { @@ -52,8 +52,10 @@ import { export interface EntityListItemProps { id: number | string projectRef: string + item: Entity isLocked: boolean isActive?: boolean + style?: CSSProperties onExportCLI: () => void } @@ -62,14 +64,15 @@ function isTableLikeEntityListItem(entity: { type?: string }) { return entity?.type === ENTITY_TYPE.TABLE || entity?.type === ENTITY_TYPE.PARTITIONED_TABLE } -const EntityListItem: ItemRenderer = ({ +const EntityListItem = ({ id, projectRef, item: entity, isLocked, isActive: _isActive, + style, onExportCLI, -}) => { +}: EntityListItemProps) => { const { data: project } = useSelectedProjectQuery() const snap = useTableEditorStateSnapshot() const { selectedSchema } = useQuerySchemaState() @@ -236,6 +239,7 @@ const EntityListItem: ItemRenderer = ({ return ( { const tableEditorTabsCleanUp = useTableEditorTabsCleanUp() - const onSelectExportCLI = async (id: number) => { - const table = await getTableEditor({ - id: id, - projectRef, - connectionString: project?.connectionString, - }) - const supaTable = table && parseSupaTable(table) - setTableToExport(supaTable) - } + const onSelectExportCLI = useCallback( + async (id: number) => { + const table = await getTableEditor({ + id: id, + projectRef, + connectionString: project?.connectionString, + }) + const supaTable = table && parseSupaTable(table) + setTableToExport(supaTable) + }, + [project?.connectionString, projectRef] + ) + + const getItemKey = useCallback( + (index: number) => { + const item = entityTypes?.[index] + return item?.id ? String(item.id) : `table-editor-entity-${index}` + }, + [entityTypes] + ) + + const entityProps = useMemo( + () => ({ + projectRef: project?.ref!, + id: Number(id), + isLocked: isSchemaLocked, + onExportCLI: () => onSelectExportCLI(Number(id)), + }), + [project?.ref, id, isSchemaLocked, onSelectExportCLI] + ) useEffect(() => { // Clean up tabs + recent items for any tables that might have been removed outside of the dashboard session @@ -164,7 +185,7 @@ export const TableEditorMenu = () => { )}
-
+
{ /> )} {(entityTypes?.length ?? 0) > 0 && ( -
- + onSelectExportCLI(Number(id)), - }} - getItemSize={() => 28} + LoaderComponent={LoaderForIconMenuItems} + itemProps={entityProps} + getItemKey={getItemKey} + getItemSize={(index) => + index !== 0 && index === entityTypes!.length ? 85 : 28 + } hasNextPage={hasNextPage} isLoadingNextPage={isFetchingNextPage} - onLoadNextPage={() => fetchNextPage()} + onLoadNextPage={fetchNextPage} />
)} diff --git a/apps/studio/components/ui/InfiniteList.tsx b/apps/studio/components/ui/InfiniteList.tsx index 8ed657cb897..eae0fb05c59 100644 --- a/apps/studio/components/ui/InfiniteList.tsx +++ b/apps/studio/components/ui/InfiniteList.tsx @@ -1,157 +1,373 @@ -import { propsAreEqual } from 'lib/helpers' -import memoize from 'memoize-one' -import { CSSProperties, ComponentType, MutableRefObject, ReactNode, memo, useRef } from 'react' -import AutoSizer from 'react-virtualized-auto-sizer' -import { VariableSizeList, areEqual } from 'react-window' -import InfiniteLoader from 'react-window-infinite-loader' -import { Skeleton } from 'ui' +import { Virtualizer, useVirtualizer } from '@tanstack/react-virtual' +import { + CSSProperties, + ComponentPropsWithRef, + ComponentType, + ElementType, + ReactNode, + Ref, + createContext, + createElement, + memo, + useContext, + useEffect, + useMemo, + useRef, + type ComponentProps, + type PropsWithChildren, +} from 'react' -/** - * Note that the loading more logic of this component works best with a cursor-based - * pagination API such that each payload response from the API returns a structure like - * { cursor, items, hasNext, hasPrevious } - */ +import { Skeleton, cn } from 'ui' -const createItemData = memoize((items, itemProps) => ({ items, ...itemProps })) +// Regular memo erases generics, so this helper adds them back +const typedMemo = JSX.Element | null>( + component: Component, + propsAreEqual?: ( + prevProps: Readonly[0]>, + nextProps: Readonly[0]> + ) => boolean +) => memo(component, propsAreEqual) as unknown as Component & { displayName?: string } -export type ItemRenderer = ComponentType< - { - item: T - listRef: MutableRefObject | null | undefined> - index: number - } & P +const createStyleObject = ({ size, start }: { size: number; start: number }): CSSProperties => ({ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: `${size}px`, + transform: `translateY(${start}px)`, +}) + +type VirtualizerInstance = Virtualizer +type VirtualItems = ReturnType + +type VirtualizerContextValue = { + virtualizer: VirtualizerInstance + virtualItems: VirtualItems +} + +const VirtualizerContext = createContext(null) + +export const VirtualizerProvider = ({ + children, + value, +}: PropsWithChildren<{ value: VirtualizerContextValue }>) => { + return {children} +} + +export const useVirtualizerContext = () => { + const context = useContext(VirtualizerContext) + if (!context) { + throw new Error('useVirtualizerContext must be used within a VirtualizerProvider') + } + return context +} + +type ExtractRefType = + ComponentPropsWithRef extends { ref?: Ref } ? RefType : never + +type ExtractScrollElementFromRefComponent = Extract< + ExtractRefType, + Element > -export interface ItemProps { - data: { - items: T[] - itemProps: P - ItemComponent: ItemRenderer - listRef: MutableRefObject | null | undefined> - LoaderComponent?: ReactNode - } - index: number - style: CSSProperties -} +type ScrollWrapperComponentConstraints = + ComponentPropsWithRef extends { className?: string } + ? ComponentPropsWithRef extends { children?: ReactNode | ReactNode[] } + ? ExtractRefType extends never + ? { ERROR_WRAPPER_COMPONENT_REQUIRES_REF_SUPPORT: never } + : ExtractRefType extends Element + ? {} + : { ERROR_WRAPPER_COMPONENT_REF_MUST_EXTEND_ELEMENT: never } + : { ERROR_WRAPPER_COMPONENT_REQUIRES_CHILDREN: never } + : { ERROR_WRAPPER_COMPONENT_REQUIRES_CLASSNAME: never } -export interface InfiniteListProps { - items?: T[] - itemProps?: P +type InfiniteListWrapperProps = { + className?: string + items: Item[] + getItemKey?: (index: number) => string + getItemSize: (index: number) => number hasNextPage?: boolean isLoadingNextPage?: boolean - getItemSize?: (index: number) => number onLoadNextPage?: () => void - ItemComponent?: ItemRenderer - LoaderComponent?: ReactNode -} + Component?: Component +} & ScrollWrapperComponentConstraints -const Item = memo(({ data, index, style }: ItemProps) => { - const { items, itemProps, ItemComponent, listRef, LoaderComponent } = data - const item = index < items.length ? items[index] : undefined - - return item ? ( -
- -
- ) : LoaderComponent !== undefined ? ( -
{LoaderComponent}
- ) : ( -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- ) -}, areEqual) - -Item.displayName = 'Item' - -function InfiniteList({ - items = [], - itemProps, +export const InfiniteListScrollWrapper = ({ + children, + items, + getItemKey, + getItemSize, hasNextPage = false, isLoadingNextPage = false, - getItemSize = () => 40, onLoadNextPage = () => {}, - ItemComponent = () => null, - LoaderComponent, -}: InfiniteListProps) { - const listRef = useRef | null>() + className, + Component, +}: PropsWithChildren>) => { + const scrollRef = useRef | null>(null) - // Only load 1 page of items at a time - // Pass an empty callback to InfiniteLoader in case it asks to load more than once - const loadMoreItems = isLoadingNextPage ? () => {} : onLoadNextPage + const rowVirtualizer = useVirtualizer, Element>({ + count: hasNextPage ? items.length + 1 : items.length, + getScrollElement: () => scrollRef.current, + getItemKey, + estimateSize: getItemSize, + overscan: 5, + }) - // Every row is loaded except for our loading indicator row - const isItemLoaded = (index: number) => { - return !hasNextPage || index < items.length + const virtualItems = rowVirtualizer.getVirtualItems() + const virtualizerContextValue = useMemo( + () => ({ + virtualizer: rowVirtualizer as unknown as Virtualizer, + virtualItems, + }), + [rowVirtualizer, virtualItems] + ) + + useEffect(() => { + const lastItem = virtualItems[virtualItems.length - 1] + if (!lastItem) return + + if (lastItem.index >= items.length - 1 && hasNextPage && !isLoadingNextPage) { + onLoadNextPage() + } + }, [virtualItems, items.length, hasNextPage, isLoadingNextPage, onLoadNextPage]) + + const WrapperToRender: Wrapper = Component ?? ('div' as Wrapper) + const wrapperProps = { + ref: (node: ExtractScrollElementFromRefComponent | null) => { + scrollRef.current = node + }, + className: cn('overflow-auto', className), + children, + } as ComponentPropsWithRef + + return ( + + + + ) +} + +type ComponentWithStylePropConstraint = + ComponentProps extends { style?: CSSProperties } + ? {} + : { ERROR_SIZER_COMPONENT_MUST_TAKE_STYLE_PROP: never } + +type InfiniteListSizerProps = { + Component?: ElementType +} & ComponentWithStylePropConstraint + +export const InfiniteListSizer = ({ + children, + Component = 'div', +}: PropsWithChildren) => { + const { virtualizer } = useVirtualizerContext() + + return ( + + {children} + + ) +} + +type RowComponentBaseProps = { + index: number + item: Item + style?: CSSProperties +} + +type InfiniteListItemProps< + Item, + ExtraProps extends object = Record, + RowComponent extends ComponentType & ExtraProps> = ComponentType< + RowComponentBaseProps & ExtraProps + >, +> = { + index: number + start: number + size: number + item: Item + itemProps?: ExtraProps + ItemComponent: RowComponent +} + +const MemoizedInfiniteListItem = typedMemo( + < + Item, + ExtraProps extends object = Record, + RowComponent extends ComponentType & ExtraProps> = ComponentType< + RowComponentBaseProps & ExtraProps + >, + >({ + index, + start, + size, + item, + itemProps, + ItemComponent, + }: InfiniteListItemProps) => { + const styleObject = useMemo( + () => createStyleObject({ size, start }), + [size, start] + ) + + const baseProps = useMemo>( + () => ({ + index, + item, + style: styleObject, + }), + [index, item, styleObject] + ) + + const combinedProps = useMemo( + () => + ({ + ...baseProps, + ...(itemProps ?? ({} as ExtraProps)), + }) as RowComponentBaseProps & ExtraProps, + [baseProps, itemProps] + ) + + // Not JSX to avoid type error with generic function component + return createElement(ItemComponent, combinedProps) } +) +MemoizedInfiniteListItem.displayName = 'MemoizedInfiniteListItem' - const itemCount = hasNextPage ? items.length + 1 : items.length - const itemData = createItemData(items, { itemProps, ItemComponent, LoaderComponent, listRef }) +type InfiniteListItemsProps< + Item, + ExtraProps extends object = Record, + RowComponent extends ComponentType & ExtraProps> = ComponentType< + RowComponentBaseProps & ExtraProps + >, +> = { + items: Item[] + itemProps?: ExtraProps + ItemComponent: RowComponent + LoaderComponent: ComponentType<{ style?: CSSProperties }> +} + +export const InfiniteListItems = < + Item, + ExtraProps extends object = Record, + RowComponent extends ComponentType & ExtraProps> = ComponentType< + RowComponentBaseProps & ExtraProps + >, +>({ + items, + itemProps, + ItemComponent, + LoaderComponent, +}: InfiniteListItemsProps) => { + const { virtualItems } = useVirtualizerContext() return ( <> -
- - {({ height, width }: { height: number; width: number }) => ( - - {({ onItemsRendered, ref }) => ( - { - ref(refy) - listRef.current = refy - }} - height={height ?? 0} - width={width ?? 0} - itemCount={itemCount} - itemData={itemData} - itemSize={getItemSize} - onItemsRendered={onItemsRendered} - > - {Item} - - )} - - )} - -
-
+ {virtualItems.map((virtualRow) => { + const isLoaderRow = virtualRow.index > items.length - 1 + const item = items[virtualRow.index] + + return isLoaderRow ? ( + + ) : ( + // Not JSX so we can pass type arguments to the generic function component + createElement(MemoizedInfiniteListItem, { + key: virtualRow.index, + index: virtualRow.index, + start: virtualRow.start, + size: virtualRow.size, + item, + itemProps, + ItemComponent, + }) + ) + })} ) } -// memo erases generics so this magic is needed -export default memo(InfiniteList, propsAreEqual) as typeof InfiniteList +type InfiniteListDefaultProps> = { + className?: string + items: Item[] + itemProps?: ItemComponentProps + getItemKey?: (index: number) => string + getItemSize: (index: number) => number + hasNextPage?: boolean + isLoadingNextPage?: boolean + onLoadNextPage?: () => void + ItemComponent: ComponentType & ItemComponentProps> + LoaderComponent: ComponentType<{ style?: CSSProperties }> +} + +export const InfiniteListDefault = < + Item, + ItemComponentProps extends object = Record, +>({ + className, + items, + itemProps, + getItemKey, + getItemSize, + hasNextPage = false, + isLoadingNextPage = false, + onLoadNextPage = () => {}, + ItemComponent, + LoaderComponent, +}: InfiniteListDefaultProps) => { + return ( + + + + + + ) +} + +export const LoaderForIconMenuItems = ({ style }: { style?: CSSProperties }) => ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+) diff --git a/apps/studio/lib/type-helpers.ts b/apps/studio/lib/type-helpers.ts new file mode 100644 index 00000000000..72551b9d9ca --- /dev/null +++ b/apps/studio/lib/type-helpers.ts @@ -0,0 +1 @@ +export type PlainObject = Record diff --git a/apps/studio/package.json b/apps/studio/package.json index 390b7fb322c..008217919c5 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -67,6 +67,7 @@ "@tanstack/react-query": "4.35.7", "@tanstack/react-query-devtools": "4.35.7", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.12", "@uidotdev/usehooks": "^2.4.1", "@vercel/functions": "^2.1.0", "@vitejs/plugin-react": "^4.3.4", @@ -125,9 +126,6 @@ "react-resizable": "3.0.5", "react-simple-maps": "4.0.0-beta.6", "react-use": "^17.5.0", - "react-virtualized-auto-sizer": "^1.0.20", - "react-window": "^1.8.6", - "react-window-infinite-loader": "^1.0.7", "reactflow": "^11.10.1", "recharts": "^2.8.0", "remark-gfm": "^3.0.1", @@ -175,9 +173,6 @@ "@types/react-dom": "catalog:", "@types/react-grid-layout": "^1.3.0", "@types/react-simple-maps": "^3.0.1", - "@types/react-virtualized-auto-sizer": "^1.0.1", - "@types/react-window": "^1.8.5", - "@types/react-window-infinite-loader": "^1.0.5", "@types/recharts": "^1.8.23", "@types/sqlstring": "^2.3.0", "@types/uuid": "^8.3.4", diff --git a/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx b/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx index 8965ff4b64e..7881db3345d 100644 --- a/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx +++ b/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx @@ -21,7 +21,7 @@ const PageLayout: NextPageWithLayout = () => { if (!project || !projectRef) return null return ( -
+
{isError && } {isSuccess ? ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b80c51482d5..66acebfa293 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -855,6 +855,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-virtual': + specifier: ^3.13.12 + version: 3.13.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@uidotdev/usehooks': specifier: ^2.4.1 version: 2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1029,15 +1032,6 @@ importers: react-use: specifier: ^17.5.0 version: 17.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-virtualized-auto-sizer: - specifier: ^1.0.20 - version: 1.0.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-window: - specifier: ^1.8.6 - version: 1.8.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-window-infinite-loader: - specifier: ^1.0.7 - version: 1.0.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) reactflow: specifier: ^11.10.1 version: 11.10.1(@types/react@18.3.3)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1174,15 +1168,6 @@ importers: '@types/react-simple-maps': specifier: ^3.0.1 version: 3.0.4 - '@types/react-virtualized-auto-sizer': - specifier: ^1.0.1 - version: 1.0.1 - '@types/react-window': - specifier: ^1.8.5 - version: 1.8.6 - '@types/react-window-infinite-loader': - specifier: ^1.0.5 - version: 1.0.7 '@types/recharts': specifier: ^1.8.23 version: 1.8.25 @@ -9073,8 +9058,8 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-virtual@3.13.6': - resolution: {integrity: sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA==} + '@tanstack/react-virtual@3.13.12': + resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -9160,8 +9145,8 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} - '@tanstack/virtual-core@3.13.6': - resolution: {integrity: sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==} + '@tanstack/virtual-core@3.13.12': + resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} '@tanstack/virtual-file-routes@1.114.12': resolution: {integrity: sha512-aR13V1kSE/kUkP4a8snmqvj82OUlR5Q/rzxICmObLCsERGfzikUc4wquOy1d/RzJgsLb8o+FiOjSWynt4T7Jhg==} @@ -9601,15 +9586,6 @@ packages: peerDependencies: '@types/react': '*' - '@types/react-virtualized-auto-sizer@1.0.1': - resolution: {integrity: sha512-GH8sAnBEM5GV9LTeiz56r4ZhMOUSrP43tAQNSRVxNexDjcNKLCEtnxusAItg1owFUFE6k0NslV26gqVClVvong==} - - '@types/react-window-infinite-loader@1.0.7': - resolution: {integrity: sha512-+CG6szhzP7akjbZ5v85yyZqS78XsJ/VVjccNFPR0bGFQf8jzL238aoNJYBP7qZDbcROIx6tiFJcT9PHzw8LAVg==} - - '@types/react-window@1.8.6': - resolution: {integrity: sha512-AVJr3A5rIO9dQQu5TwTN0lP2c1RtuqyyZGCt7PGP8e5gUpn1PuQRMJb/u3UpdbwTHh4wbEi33UMW5NI0IXt1Mg==} - '@types/react@18.3.3': resolution: {integrity: sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==} @@ -17111,26 +17087,6 @@ packages: react: '*' react-dom: '*' - react-virtualized-auto-sizer@1.0.20: - resolution: {integrity: sha512-OdIyHwj4S4wyhbKHOKM1wLSj/UDXm839Z3Cvfg2a9j+He6yDa6i5p0qQvEiCnyQlGO/HyfSnigQwuxvYalaAXA==} - peerDependencies: - react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc - react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc - - react-window-infinite-loader@1.0.9: - resolution: {integrity: sha512-5Hg89IdU4Vrp0RT8kZYKeTIxWZYhNkVXeI1HbKo01Vm/Z7qztDvXljwx16sMzsa9yapRJQW3ODZfMUw38SOWHw==} - engines: {node: '>8.0.0'} - peerDependencies: - react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 - react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 - - react-window@1.8.9: - resolution: {integrity: sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q==} - engines: {node: '>8.0.0'} - peerDependencies: - react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 - react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 - react-wrap-balancer@1.1.0: resolution: {integrity: sha512-EhF3jOZm5Fjx+Cx41e423qOv2c2aOvXAtym2OHqrGeMUnwERIyNsRBgnfT3plB170JmuYvts8K2KSPEIerKr5A==} peerDependencies: @@ -22705,7 +22661,7 @@ snapshots: '@graphql-tools/executor-legacy-ws@1.1.17(graphql@16.11.0)': dependencies: '@graphql-tools/utils': 10.8.6(graphql@16.11.0) - '@types/ws': 8.5.10 + '@types/ws': 8.18.1 graphql: 16.11.0 isomorphic-ws: 5.0.0(ws@8.18.3) tslib: 2.8.1 @@ -22964,7 +22920,7 @@ snapshots: '@floating-ui/react': 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@react-aria/focus': 3.20.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@react-aria/interactions': 3.25.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@tanstack/react-virtual': 3.13.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-virtual': 3.13.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) use-sync-external-store: 1.5.0(react@18.3.1) @@ -28968,9 +28924,9 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@tanstack/react-virtual@3.13.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tanstack/react-virtual@3.13.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@tanstack/virtual-core': 3.13.6 + '@tanstack/virtual-core': 3.13.12 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -29192,7 +29148,7 @@ snapshots: '@tanstack/table-core@8.21.3': {} - '@tanstack/virtual-core@3.13.6': {} + '@tanstack/virtual-core@3.13.12': {} '@tanstack/virtual-file-routes@1.114.12': {} @@ -29723,19 +29679,6 @@ snapshots: dependencies: '@types/react': 18.3.3 - '@types/react-virtualized-auto-sizer@1.0.1': - dependencies: - '@types/react': 18.3.3 - - '@types/react-window-infinite-loader@1.0.7': - dependencies: - '@types/react': 18.3.3 - '@types/react-window': 1.8.6 - - '@types/react-window@1.8.6': - dependencies: - '@types/react': 18.3.3 - '@types/react@18.3.3': dependencies: '@types/prop-types': 15.7.8 @@ -39021,23 +38964,6 @@ snapshots: ts-easing: 0.2.0 tslib: 2.6.2 - react-virtualized-auto-sizer@1.0.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react-window-infinite-loader@1.0.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react-window@1.8.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@babel/runtime': 7.26.10 - memoize-one: 5.2.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-wrap-balancer@1.1.0(react@18.3.1): dependencies: react: 18.3.1