From c0c824809e36953c5893b780b62a5a804c9bdff3 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:45:16 -0500 Subject: [PATCH] feat(studio): add infinite pagination for buckets policies (#41277) ## Summary of changes Buckets policies page at /project/_/storage/files/policies now uses the paginated buckets endpoint to fetch buckets. ## Before Used a non-paginated query. ## After To support a paginated query, we now have: - Infinite scroll loading behavior - Fixed some of the buckets utilities to account for policy references to buckets that haven't loaded yet --- .../interfaces/Storage/Storage.utils.ts | 49 ++-- .../StoragePolicies/StoragePolicies.tsx | 163 +++++------- .../StoragePoliciesBucketRow.tsx | 119 +++++---- .../StoragePoliciesBucketsSection.tsx | 234 ++++++++++++++++++ .../layouts/MainScrollContainerContext.tsx | 28 +++ .../layouts/ProjectLayout/index.tsx | 8 +- apps/studio/components/ui/InfiniteList.tsx | 6 + apps/studio/hooks/misc/useDebouncedValue.ts | 16 ++ apps/studio/lib/helpers.ts | 13 + apps/studio/pages/_app.tsx | 5 +- .../src/ShimmeringLoader/index.tsx | 35 ++- 11 files changed, 477 insertions(+), 199 deletions(-) create mode 100644 apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketsSection.tsx create mode 100644 apps/studio/components/layouts/MainScrollContainerContext.tsx create mode 100644 apps/studio/hooks/misc/useDebouncedValue.ts diff --git a/apps/studio/components/interfaces/Storage/Storage.utils.ts b/apps/studio/components/interfaces/Storage/Storage.utils.ts index 093bfdfb8af..540f38ebbfb 100644 --- a/apps/studio/components/interfaces/Storage/Storage.utils.ts +++ b/apps/studio/components/interfaces/Storage/Storage.utils.ts @@ -1,8 +1,9 @@ import { PostgresPolicy } from '@supabase/postgres-meta' -import { difference, groupBy } from 'lodash' +import { difference } from 'lodash' import { useRouter } from 'next/router' import { Bucket } from 'data/storage/buckets-query' +import { createWrappedSymbol } from 'lib/helpers' import { STORAGE_CLIENT_LIBRARY_MAPPINGS } from './Storage.constants' import type { StoragePolicyFormField } from './Storage.types' @@ -16,13 +17,18 @@ const shortHash = (str: string) => { return new Uint32Array([hash])[0].toString(36) } +export type PoliciesByBucket = { name: string | Symbol; policies: PostgresPolicy[] }[] + /** * Formats the policies from the objects table in the storage schema * to be consumable for the storage policies dashboard. - * Output: [{ bucket: , policies: }] - * @param {Array} policies: All policies from a table in a schema + * + * @param policies All policies from a table in a schema */ -export const formatPoliciesForStorage = (buckets: Bucket[], policies: PostgresPolicy[]) => { +export const formatPoliciesForStorage = ( + buckets: Bucket[], + policies: PostgresPolicy[] +): PoliciesByBucket => { if (policies.length === 0) return [] /** @@ -34,15 +40,20 @@ export const formatPoliciesForStorage = (buckets: Bucket[], policies: PostgresPo */ const formattedPolicies = formatStoragePolicies(buckets, policies) - /** - * Package policies by grouping them by bucket: - * [{ name: , policies: }] - */ const policiesByBucket = groupPoliciesByBucket(formattedPolicies) - return policiesByBucket } +/** + * Policy that belongs to a bucket which is not loaded yet (might not have been + * paginated to yet, or might have been deleted) + */ +export const UNKNOWN_BUCKET_SYMBOL = createWrappedSymbol('unknown-bucket', 'Unknown') +/** + * Policy that is not associated with a specific bucket + */ +export const UNGROUPED_POLICY_SYMBOL = createWrappedSymbol('ungrouped-policy', 'Ungrouped') + const formatStoragePolicies = (buckets: Bucket[], policies: PostgresPolicy[]) => { const availableBuckets = buckets.map((bucket) => bucket.name) const formattedPolicies = policies.map((policy) => { @@ -53,16 +64,16 @@ const formatStoragePolicies = (buckets: Bucket[], policies: PostgresPolicy[]) => ? extractBucketNameFromDefinition(policyDefinition) : extractBucketNameFromDefinition(policyCheck) - if (bucketName && availableBuckets.includes(bucketName)) { - // [JOSHEN TODO] We cannot override definition here anymore cause we're gonna be using the auth editor - // const definition = policyDefinition !== null ? policyDefinition : policyCheck + if (bucketName) { + const isBucketLoaded = availableBuckets.includes(bucketName) + return { ...policy, - bucket: bucketName, + bucket: isBucketLoaded ? bucketName : UNKNOWN_BUCKET_SYMBOL, } } - return { ...policy, bucket: 'Ungrouped' } + return { ...policy, bucket: UNGROUPED_POLICY_SYMBOL } }) return formattedPolicies @@ -78,11 +89,11 @@ export const extractBucketNameFromDefinition = (definition: string | null) => { return bucketDefinition ? bucketDefinition.split("'")[1] : null } -const groupPoliciesByBucket = (policies: (PostgresPolicy & { bucket: string })[]) => { - const policiesByBucket = groupBy(policies, 'bucket') - return Object.keys(policiesByBucket).map((bucketName) => { - return { name: bucketName, policies: policiesByBucket[bucketName] } - }) +const groupPoliciesByBucket = (policies: (PostgresPolicy & { bucket: string | Symbol })[]) => { + const policiesByBucket = Map.groupBy(policies, (policy) => policy.bucket) + return [ + ...policiesByBucket.entries().map(([bucketName, policies]) => ({ name: bucketName, policies })), + ] } export const createPayloadsForAddPolicy = ( diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx index 641f0acd8eb..a602a9e0051 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx @@ -1,22 +1,19 @@ import { PostgresPolicy } from '@supabase/postgres-meta' import { useParams } from 'common' import { isEmpty } from 'lodash' -import { Search, X } from 'lucide-react' import { parseAsString, useQueryState } from 'nuqs' import { useMemo, useState } from 'react' import { toast } from 'sonner' import PolicyEditorModal from 'components/interfaces/Auth/Policies/PolicyEditorModal' -import { NoSearchResults } from 'components/ui/NoSearchResults' import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' import { useDatabasePolicyCreateMutation } from 'data/database-policies/database-policy-create-mutation' import { useDatabasePolicyDeleteMutation } from 'data/database-policies/database-policy-delete-mutation' import { useDatabasePolicyUpdateMutation } from 'data/database-policies/database-policy-update-mutation' -import { useBucketsQuery } from 'data/storage/buckets-query' +import { usePaginatedBucketsQuery } from 'data/storage/buckets-query' +import { useDebouncedValue } from 'hooks/misc/useDebouncedValue' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { Button } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns' -import { Input } from 'ui-patterns/DataInputs/Input' import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog' import { PageContainer } from 'ui-patterns/PageContainer' import { @@ -27,10 +24,10 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' -import { formatPoliciesForStorage } from '../Storage.utils' +import { formatPoliciesForStorage, UNGROUPED_POLICY_SYMBOL } from '../Storage.utils' import { StoragePoliciesBucketRow } from './StoragePoliciesBucketRow' +import { BucketsPolicies, type SelectBucketPolicyForAction } from './StoragePoliciesBucketsSection' import StoragePoliciesEditPolicyModal from './StoragePoliciesEditPolicyModal' -import StoragePoliciesPlaceholder from './StoragePoliciesPlaceholder' export const StoragePolicies = () => { const { ref: projectRef } = useParams() @@ -46,8 +43,19 @@ export const StoragePolicies = () => { 'search', parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true }) ) + const debouncedSearchString = useDebouncedValue(searchString, 250) - const { data: buckets = [], isPending: isLoadingBuckets } = useBucketsQuery({ projectRef }) + const { + data: bucketsData, + isPending: isLoadingBuckets, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = usePaginatedBucketsQuery({ + projectRef, + search: debouncedSearchString || undefined, + }) + const buckets = useMemo(() => bucketsData?.pages.flatMap((page) => page) ?? [], [bucketsData]) const { data: policies = [], @@ -81,50 +89,46 @@ export const StoragePolicies = () => { const showGeneralPolicyEditor = !isEmpty(isEditingPolicyForBucket) && !showStoragePolicyEditor - // Policies under storage.objects - const storageObjectsPolicies = policies.filter( - (x) => x.schema === 'storage' && x.table === 'objects' - ) - const formattedStorageObjectPolicies = formatPoliciesForStorage(buckets, storageObjectsPolicies) - const ungroupedPolicies = - formattedStorageObjectPolicies.find((x) => x.name === 'Ungrouped')?.policies ?? [] - // Policies under storage.buckets const storageBucketPolicies = policies.filter( (x) => x.schema === 'storage' && x.table === 'buckets' ) + // Policies under storage.objects + const storageObjectsPolicies = policies.filter( + (x) => x.schema === 'storage' && x.table === 'objects' + ) - /** - * Filter buckets based on search string - * - Filter buckets by name matching the search string - * - Show all policies for filtered buckets (policies are not filtered) - */ - const filteredBucketsWithPolicies = useMemo(() => { - const searchFilter = searchString?.toLowerCase() || '' + const formattedStorageObjectPolicies = useMemo( + () => formatPoliciesForStorage(buckets, storageObjectsPolicies), + [buckets, storageObjectsPolicies] + ) - // Filter buckets by name if search filter is present - const filteredBucketsList = searchFilter - ? buckets.filter((bucket) => bucket.name.toLowerCase().includes(searchFilter)) - : buckets + const ungroupedPolicies = + formattedStorageObjectPolicies.find((x) => x.name === UNGROUPED_POLICY_SYMBOL)?.policies ?? [] + const bucketsWithPolicies = useMemo(() => { // Get policies for filtered buckets (show all policies, don't filter them) - // Show all filtered buckets, even if they don't have policies (similar to auth/policies.tsx) - const filteredBucketsWithPoliciesList = filteredBucketsList.map((bucket) => { - const policies = + // Show all filtered buckets, even if they don't have policies + return buckets.map((bucket) => { + const bucketPolicies = formattedStorageObjectPolicies.find((x) => x.name === bucket.name)?.policies ?? [] - return { bucket, policies } + return { bucket, policies: bucketPolicies } }) + }, [buckets, formattedStorageObjectPolicies]) - // Schema-level policies should always be shown, unaffected by search filter - return filteredBucketsWithPoliciesList - }, [buckets, searchString, formattedStorageObjectPolicies]) - - const onSelectPolicyAdd = (bucketName = '', table = '') => { + const onSelectPolicyAdd: SelectBucketPolicyForAction['addPolicy'] = ( + bucketName = '', + table = '' + ) => { setSelectedPolicyToEdit(undefined) setIsEditingPolicyForBucket({ bucket: bucketName, table }) } - const onSelectPolicyEdit = (policy: any, bucketName = '', table = '') => { + const onSelectPolicyEdit: SelectBucketPolicyForAction['editPolicy'] = ( + policy, + bucketName = '', + table = '' + ) => { setIsEditingPolicyForBucket({ bucket: bucketName, table }) setSelectedPolicyToEdit(policy) } @@ -133,7 +137,8 @@ export const StoragePolicies = () => { setIsEditingPolicyForBucket(undefined) } - const onSelectPolicyDelete = (policy: any) => setSelectedPolicyToDelete(policy) + const onSelectPolicyDelete: SelectBucketPolicyForAction['deletePolicy'] = (policy: any) => + setSelectedPolicyToDelete(policy) const onCancelPolicyDelete = () => setSelectedPolicyToDelete(undefined) const onSavePolicySuccess = async () => { @@ -237,72 +242,22 @@ export const StoragePolicies = () => { ) : (
- - - - Buckets - - Write policies for each bucket to control access to the bucket and its contents - - - - - {buckets.length === 0 && } - - {buckets.length > 0 && ( -
- { - const str = e.target.value - setSearchString(str) - }} - icon={} - actions={ - searchString ? ( -
- )} - - {searchString.length > 0 && filteredBucketsWithPolicies.length === 0 && ( - setSearchString('')} - /> - )} - - {/* Sections for policies grouped by buckets */} -
- {filteredBucketsWithPolicies.map(({ bucket, policies }) => { - return ( - - ) - })} -
-
-
+ diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx index 7296b5168a4..81ece470257 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx @@ -5,6 +5,7 @@ import { PolicyRow } from 'components/interfaces/Auth/Policies/PolicyTableRow/Po import { Bucket } from 'data/storage/buckets-query' import { FilesBucket as FilesBucketIcon } from 'icons' +import { forwardRef, type CSSProperties } from 'react' import { Badge, Button, @@ -24,64 +25,72 @@ interface StoragePoliciesBucketRowProps { label: string bucket?: Bucket policies: PostgresPolicy[] + style?: CSSProperties onSelectPolicyAdd: (bucketName: string | undefined, table: string) => void onSelectPolicyEdit: (policy: PostgresPolicy, bucketName: string, table: string) => void onSelectPolicyDelete: (policy: PostgresPolicy) => void } -export const StoragePoliciesBucketRow = ({ - table = '', - label = '', - bucket, - policies = [], - onSelectPolicyAdd = noop, - onSelectPolicyEdit = noop, - onSelectPolicyDelete = noop, -}: StoragePoliciesBucketRowProps) => { - return ( - - -
- -
- {label} - {bucket?.public && Public} +export const StoragePoliciesBucketRow = forwardRef( + ( + { + table = '', + label = '', + bucket, + policies = [], + style, + onSelectPolicyAdd = noop, + onSelectPolicyEdit = noop, + onSelectPolicyDelete = noop, + }, + ref + ) => { + return ( + + +
+ +
+ {label} + {bucket?.public && Public} +
-
- - - {policies.length === 0 ? ( - -

No policies created yet

-
- ) : ( - - - - - Name - Command - Applied to - - Actions - - - - - {policies.map((policy) => ( - onSelectPolicyEdit(p, bucket?.name ?? '', table)} - onSelectDeletePolicy={onSelectPolicyDelete} - /> - ))} - -
-
- )} - - ) -} + + + {policies.length === 0 ? ( + +

No policies created yet

+
+ ) : ( + + + + + Name + Command + Applied to + + Actions + + + + + {policies.map((policy) => ( + onSelectPolicyEdit(p, bucket?.name ?? '', table)} + onSelectDeletePolicy={onSelectPolicyDelete} + /> + ))} + +
+
+ )} + + ) + } +) +StoragePoliciesBucketRow.displayName = 'StoragePoliciesBucketRow' diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketsSection.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketsSection.tsx new file mode 100644 index 00000000000..11b5b443e73 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketsSection.tsx @@ -0,0 +1,234 @@ +import { PostgresPolicy } from '@supabase/postgres-meta' +import { useVirtualizer } from '@tanstack/react-virtual' +import { ChevronUp, Search, X } from 'lucide-react' +import { forwardRef, useEffect, useState, type HTMLAttributes, type ReactNode } from 'react' + +import { useMainScrollContainer } from 'components/layouts/MainScrollContainerContext' +import { NoSearchResults } from 'components/ui/NoSearchResults' +import { type Bucket } from 'data/storage/buckets-query' +import { useStaticEffectEvent } from 'hooks/useStaticEffectEvent' +import { + Button, + cn, + Collapsible_Shadcn_, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, +} from 'ui' +import { ShimmeringLoader } from 'ui-patterns' +import { Input } from 'ui-patterns/DataInputs/Input' +import { + PageSection, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' +import { StoragePoliciesBucketRow } from './StoragePoliciesBucketRow' +import StoragePoliciesPlaceholder from './StoragePoliciesPlaceholder' + +export type SelectBucketPolicyForAction = { + addPolicy: (bucketName?: string, table?: string) => void + editPolicy: (policy: PostgresPolicy, bucketName?: string, table?: string) => void + deletePolicy: (policy: PostgresPolicy) => void +} + +type BucketsPoliciesProps = { + buckets: { bucket: Bucket; policies: PostgresPolicy[] }[] + search?: string + debouncedSearch?: string + setSearch: (search: string) => void + actions: SelectBucketPolicyForAction + pagination: { + hasNextPage: boolean + isFetchingNextPage: boolean + fetchNextPage: () => void + } +} + +export const BucketsPolicies = ({ + buckets, + search, + debouncedSearch, + setSearch, + actions, + pagination, +}: BucketsPoliciesProps): ReactNode => { + const [expanded, setExpanded] = useState(true) + + const showEmptyState = buckets.length === 0 && (!debouncedSearch || debouncedSearch.length === 0) + + return ( + + + + + Buckets + + Write policies for each bucket to control access to the bucket and its contents + + + + + + + + + {showEmptyState && } + + {buckets.length > 0 && ( +
+ { + const str = e.target.value + setSearch(str) + }} + icon={} + actions={ + search ? ( +
+ )} + + {!!search && search.length > 0 && buckets.length === 0 && ( + setSearch('')} /> + )} + + +
+
+
+
+ ) +} + +type BucketsPoliciesVirtualizedListProps = { + items: { bucket: Bucket; policies: PostgresPolicy[] }[] + actions: SelectBucketPolicyForAction + pagination: BucketsPoliciesProps['pagination'] +} + +const BucketsPoliciesVirtualizedList = ({ + items, + actions, + pagination, +}: BucketsPoliciesVirtualizedListProps) => { + const { hasNextPage, isFetchingNextPage, fetchNextPage } = pagination + + const itemCount = hasNextPage ? items.length + 1 : items.length + + const scrollElement = useMainScrollContainer() + const virtualizer = useVirtualizer({ + count: itemCount, + estimateSize: () => 129, + overscan: 5, + getItemKey: (index) => items[index]?.bucket.name ?? `bucket-${index}`, + getScrollElement: () => scrollElement, + }) + + const virtualItems = virtualizer.getVirtualItems() + const lastItem = virtualItems[virtualItems.length - 1] + + const fetchNext = useStaticEffectEvent(() => { + if (lastItem && lastItem.index >= items.length - 1 && hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }) + useEffect(fetchNext, [lastItem, fetchNext]) + + return ( +
+ {virtualItems.map((virtualRow) => { + const isLoaderRow = virtualRow.index > items.length - 1 + const commonStyle = { + position: 'absolute' as const, + top: 0, + left: 0, + width: '100%', + transform: `translateY(${virtualRow.start}px)`, + } + + if (isLoaderRow) { + return ( + + ) + } + + const item = items[virtualRow.index] + if (!item) return null + + return ( +
+ +
+ ) + })} +
+ ) +} + +type BucketsPoliciesLoaderProps = HTMLAttributes + +const BucketsPoliciesLoader = forwardRef( + (props: BucketsPoliciesLoaderProps, ref) => ( +
+

Loading more...

+ +
+ ) +) +BucketsPoliciesLoader.displayName = 'BucketsPoliciesLoader' diff --git a/apps/studio/components/layouts/MainScrollContainerContext.tsx b/apps/studio/components/layouts/MainScrollContainerContext.tsx new file mode 100644 index 00000000000..e56fc29fe21 --- /dev/null +++ b/apps/studio/components/layouts/MainScrollContainerContext.tsx @@ -0,0 +1,28 @@ +import { createContext, useContext, useState, type ReactNode } from 'react' + +const MainScrollContainerContext = createContext(null) +const SetMainScrollContainerContext = createContext< + React.Dispatch> +>(() => {}) + +export const MainScrollContainerProvider = ({ children }: { children: ReactNode }) => { + const [container, setContainer] = useState(null) + + return ( + + + {children} + + + ) +} + +export const useSetMainScrollContainer = () => { + const context = useContext(SetMainScrollContainerContext) + return context +} + +export const useMainScrollContainer = () => { + const context = useContext(MainScrollContainerContext) + return context +} diff --git a/apps/studio/components/layouts/ProjectLayout/index.tsx b/apps/studio/components/layouts/ProjectLayout/index.tsx index b6e717c56c8..b987038cf74 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.tsx @@ -2,7 +2,7 @@ import Head from 'next/head' import { useRouter } from 'next/router' import { forwardRef, Fragment, PropsWithChildren, ReactNode, useEffect } from 'react' -import { useFlag, useParams } from 'common' +import { mergeRefs, useFlag, useParams } from 'common' import { CreateBranchModal } from 'components/interfaces/BranchManagement/CreateBranchModal' import { ProjectAPIDocs } from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs' import { ResourceExhaustionWarningBanner } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner' @@ -17,6 +17,7 @@ import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { cn, LogoLoader, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav' import { useEditorType } from '../editors/EditorsLayout.hooks' +import { useSetMainScrollContainer } from '../MainScrollContainerContext' import BuildingState from './BuildingState' import ConnectingState from './ConnectingState' import { LoadingState } from './LoadingState' @@ -87,6 +88,9 @@ export const ProjectLayout = forwardRef
{showPausedState ? (
diff --git a/apps/studio/components/ui/InfiniteList.tsx b/apps/studio/components/ui/InfiniteList.tsx index c767c247329..64ed180bf60 100644 --- a/apps/studio/components/ui/InfiniteList.tsx +++ b/apps/studio/components/ui/InfiniteList.tsx @@ -89,6 +89,7 @@ type InfiniteListWrapperProps = { items: Item[] getItemKey?: (index: number) => string getItemSize: (index: number) => number + gap?: number hasNextPage?: boolean isLoadingNextPage?: boolean onLoadNextPage?: () => void @@ -100,6 +101,7 @@ export const InfiniteListScrollWrapper = string getItemSize: (index: number) => number + gap?: number hasNextPage?: boolean isLoadingNextPage?: boolean onLoadNextPage?: () => void @@ -320,6 +324,7 @@ export const InfiniteListDefault = < itemProps, getItemKey, getItemSize, + gap, hasNextPage = false, isLoadingNextPage = false, onLoadNextPage = () => {}, @@ -332,6 +337,7 @@ export const InfiniteListDefault = < items={items} getItemKey={getItemKey} getItemSize={getItemSize} + gap={gap} hasNextPage={hasNextPage} isLoadingNextPage={isLoadingNextPage} onLoadNextPage={onLoadNextPage} diff --git a/apps/studio/hooks/misc/useDebouncedValue.ts b/apps/studio/hooks/misc/useDebouncedValue.ts new file mode 100644 index 00000000000..7eab1a25850 --- /dev/null +++ b/apps/studio/hooks/misc/useDebouncedValue.ts @@ -0,0 +1,16 @@ +import { useState } from 'react' +import { useDebounce } from 'react-use' + +export const useDebouncedValue = (value: string, delay: number) => { + const [debouncedValue, setDebouncedValue] = useState(value) + + useDebounce( + () => { + setDebouncedValue(value) + }, + delay, + [value] + ) + + return debouncedValue +} diff --git a/apps/studio/lib/helpers.ts b/apps/studio/lib/helpers.ts index a0a21bd26e8..a82e0c00dc5 100644 --- a/apps/studio/lib/helpers.ts +++ b/apps/studio/lib/helpers.ts @@ -353,4 +353,17 @@ export const cleanPointerEventsNoneOnBody = (timeoutMs: number = 300) => { } } +export const createWrappedSymbol = (name: string, display: string): Symbol => { + const sym = Symbol(name) + const wrapper = Object(sym) + + wrapper.toString = () => display + + Object.freeze(wrapper) + return wrapper +} + +// Intentional for generic use; does not affect type safety since this branch is +// unreachable. +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function neverGuard(_: never): any {} diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index 6b78331cff0..3f4c83199b1 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -48,6 +48,7 @@ import { FeaturePreviewContextProvider } from 'components/interfaces/App/Feature import FeaturePreviewModal from 'components/interfaces/App/FeaturePreview/FeaturePreviewModal' import { MonacoThemeProvider } from 'components/interfaces/App/MonacoThemeProvider' import { RouteValidationWrapper } from 'components/interfaces/App/RouteValidationWrapper' +import { MainScrollContainerProvider } from 'components/layouts/MainScrollContainerContext' import { GlobalErrorBoundaryState } from 'components/ui/ErrorBoundary/GlobalErrorBoundaryState' import { useRootQueryClient } from 'data/query-client' import { customFont, sourceCodePro } from 'fonts' @@ -160,7 +161,9 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { - {getLayout()} + + {getLayout()} + diff --git a/packages/ui-patterns/src/ShimmeringLoader/index.tsx b/packages/ui-patterns/src/ShimmeringLoader/index.tsx index 025351459c5..9e6144564d6 100644 --- a/packages/ui-patterns/src/ShimmeringLoader/index.tsx +++ b/packages/ui-patterns/src/ShimmeringLoader/index.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties } from 'react' +import { forwardRef, type CSSProperties } from 'react' import { Card, cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' export interface ShimmeringLoader { @@ -8,23 +8,22 @@ export interface ShimmeringLoader { animationDelay?: number } -export const ShimmeringLoader = ({ - className, - style, - delayIndex = 0, - animationDelay = 150, -}: ShimmeringLoader) => { - return ( -
- ) -} +export const ShimmeringLoader = forwardRef( + ({ className, style, delayIndex = 0, animationDelay = 150 }, ref) => { + return ( +
+ ) + } +) +ShimmeringLoader.displayName = 'ShimmeringLoader' interface GenericSkeletonLoaderProps { className?: string