From 784f77d398068e9626cd37e39b17fb5be11d4658 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Thu, 27 Nov 2025 12:26:25 +0200 Subject: [PATCH 01/14] Add example code for Vector buckets (#40440) * Add examples. * Move the button for creating a vector bucket outside of the dialog. * Install the wrappers extension if missing when creating a vector bucket. * Refactor the quick start code for vector buckets. * Fix a type error. * chore(studio): vector tables UI improvements (#40598) * Add examples. * clearer examples * clearer dimension samples * quick start as sheet * improve structure * cleanup * icons and text size * improve snippet code * icons * sql editor link * cleanup --------- Co-authored-by: Ivan Vasilov * Minor naming fix. * Fix the link to the Table Editor. * Smol fix + refactor and clean up * Nit * Nit improvement * Make the SQL option default. * fix docs link * fix example code * remove redundant card * Check if the project has secret keys or service role keys. Remove the NEXT_PUBLIC from the env vars to avoid exposing them to the FE. --------- Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com> Co-authored-by: Joshen Lim --- .../interfaces/Storage/EmptyBucketState.tsx | 11 +- .../CreateVectorBucketDialog.tsx | 96 +++--- .../VectorBucketCallouts.tsx | 136 +++++++++ .../VectorBucketTableExamplesSheet.tsx | 282 ++++++++++++++++++ .../index.tsx} | 222 ++++---------- .../Storage/VectorBuckets/index.tsx | 13 +- 6 files changed, 553 insertions(+), 207 deletions(-) create mode 100644 apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketCallouts.tsx create mode 100644 apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx rename apps/studio/components/interfaces/Storage/VectorBuckets/{VectorBucketDetails.tsx => VectorBucketDetails/index.tsx} (61%) diff --git a/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx b/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx index 6301629a7416c..54884b6e5b431 100644 --- a/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx +++ b/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx @@ -3,14 +3,19 @@ import { EmptyStatePresentational } from 'ui-patterns' import { CreateAnalyticsBucketModal } from './AnalyticsBuckets/CreateAnalyticsBucketModal' import { CreateBucketModal } from './CreateBucketModal' import { BUCKET_TYPES } from './Storage.constants' -import { CreateVectorBucketDialog } from './VectorBuckets/CreateVectorBucketDialog' +import { CreateVectorBucketButton } from './VectorBuckets/CreateVectorBucketDialog' interface EmptyBucketStateProps { bucketType: keyof typeof BUCKET_TYPES className?: string + onCreateBucket?: () => void } -export const EmptyBucketState = ({ bucketType, className }: EmptyBucketStateProps) => { +export const EmptyBucketState = ({ + bucketType, + className, + onCreateBucket, +}: EmptyBucketStateProps) => { const config = BUCKET_TYPES[bucketType] return ( @@ -30,7 +35,7 @@ export const EmptyBucketState = ({ bucketType, className }: EmptyBucketStateProp buttonClassName="w-fit" /> )} - {bucketType === 'vectors' && } + {bucketType === 'vectors' && } ) } diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx index a0ec45ed29afe..2ab963bcf53f4 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx @@ -1,8 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { Plus } from 'lucide-react' -import { parseAsBoolean, useQueryState } from 'nuqs' -import { useEffect, useState } from 'react' +import { MouseEventHandler, useEffect, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import z from 'zod' @@ -10,6 +9,7 @@ import z from 'zod' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { InlineLink } from 'components/ui/InlineLink' +import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation' import { useSchemaCreateMutation } from 'data/database/schema-create-mutation' import { useS3VectorsWrapperCreateMutation } from 'data/storage/s3-vectors-wrapper-create-mutation' import { useVectorBucketCreateMutation } from 'data/storage/vector-bucket-create-mutation' @@ -28,7 +28,6 @@ import { DialogSection, DialogSectionSeparator, DialogTitle, - DialogTrigger, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, @@ -88,18 +87,46 @@ const formId = 'create-storage-bucket-form' export type CreateBucketForm = z.infer -export const CreateVectorBucketDialog = () => { +export const CreateVectorBucketButton = ({ + onClick, +}: { + onClick?: MouseEventHandler +}) => { + const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') + + return ( + } + disabled={!canCreateBuckets} + onClick={onClick} + tooltip={{ + content: { + side: 'bottom', + text: !canCreateBuckets ? 'You need additional permissions to create buckets' : undefined, + }, + }} + > + New bucket + + ) +} + +export const CreateVectorBucketDialog = ({ + visible, + setVisible, +}: { + visible: boolean + setVisible: (visible: boolean) => void +}) => { const { ref } = useParams() const { data: org } = useSelectedOrganizationQuery() const { data: project } = useSelectedProjectQuery() const [isLoading, setIsLoading] = useState(false) - const [visible, setVisible] = useQueryState( - 'new', - parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) - ) - const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') - const { data } = useVectorBucketsQuery({ projectRef: ref }) const form = useForm({ @@ -112,7 +139,8 @@ export const CreateVectorBucketDialog = () => { onError: () => {}, }) - const { state: wrappersExtensionState } = useS3VectorsWrapperExtension() + const { extension: wrappersExtension, state: wrappersExtensionState } = + useS3VectorsWrapperExtension() const { mutateAsync: createS3VectorsWrapper } = useS3VectorsWrapperCreateMutation() @@ -120,6 +148,8 @@ export const CreateVectorBucketDialog = () => { onError: () => {}, }) + const { mutateAsync: enableExtension } = useDatabaseExtensionEnableMutation() + const onSubmit: SubmitHandler = async (values) => { if (!ref) return console.error('Project ref is required') @@ -138,7 +168,25 @@ export const CreateVectorBucketDialog = () => { } try { - if (wrappersExtensionState === 'installed') { + if (!wrappersExtension) throw new Error('Unable to find wrappers extension.') + if (wrappersExtensionState === 'not-installed') { + // when it's not installed, we need to enable the extension and create the wrapper + await enableExtension({ + projectRef: project?.ref!, + connectionString: project?.connectionString, + name: wrappersExtension.name, + schema: wrappersExtension.schema ?? 'extensions', + version: wrappersExtension.default_version, + }) + + await createS3VectorsWrapper({ bucketName: values.name }) + + await createSchema({ + projectRef: project?.ref, + connectionString: project?.connectionString, + name: getVectorBucketFDWSchemaName(values.name), + }) + } else if (wrappersExtensionState === 'installed') { await createS3VectorsWrapper({ bucketName: values.name }) await createSchema({ @@ -166,32 +214,10 @@ export const CreateVectorBucketDialog = () => { useEffect(() => { if (!visible) form.reset() - }, [visible]) + }, [visible, form]) return ( - - } - disabled={!canCreateBuckets} - onClick={() => setVisible(true)} - tooltip={{ - content: { - side: 'bottom', - text: !canCreateBuckets - ? 'You need additional permissions to create buckets' - : undefined, - }, - }} - > - New bucket - - - Create vector bucket diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketCallouts.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketCallouts.tsx new file mode 100644 index 0000000000000..249e12f77c6fc --- /dev/null +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketCallouts.tsx @@ -0,0 +1,136 @@ +import Link from 'next/link' +import { toast } from 'sonner' + +import { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types' +import { ScaffoldSection } from 'components/layouts/Scaffold' +import { InlineLink } from 'components/ui/InlineLink' +import { DatabaseExtension } from 'data/database-extensions/database-extensions-query' +import { useSchemaCreateMutation } from 'data/database/schema-create-mutation' +import { useS3VectorsWrapperCreateMutation } from 'data/storage/s3-vectors-wrapper-create-mutation' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { DOCS_URL } from 'lib/constants' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/admonition' +import { getVectorBucketFDWSchemaName } from '../VectorBuckets.utils' + +export const ExtensionNotInstalled = ({ + projectRef, + wrapperMeta, + wrappersExtension, +}: { + projectRef: string + wrapperMeta: WrapperMeta + wrappersExtension: DatabaseExtension +}) => { + const databaseNeedsUpgrading = + (wrappersExtension?.default_version ?? '') < (wrapperMeta.minimumExtensionVersion ?? '') + + return ( + + +

+ The Wrappers extension is required in order to query vector tables.{' '} + {databaseNeedsUpgrading && + 'Please first upgrade your database and then install the extension.'}{' '} + + Learn more + +

+ +
+
+ ) +} + +export const ExtensionNeedsUpgrade = ({ + projectRef, + wrapperMeta, + wrappersExtension, +}: { + projectRef: string + wrapperMeta: WrapperMeta + wrappersExtension: DatabaseExtension +}) => { + // [Joshen] Default version is what's on the DB, so if the installed version is already the default version + // but still doesnt meet the minimum extension version, then DB upgrade is required + const databaseNeedsUpgrading = + wrappersExtension?.installed_version === wrappersExtension?.default_version + + return ( + + +

+ The {wrapperMeta.label} wrapper requires a minimum extension version of{' '} + {wrapperMeta.minimumExtensionVersion}. You have version{' '} + {wrappersExtension?.installed_version} installed. Please{' '} + {databaseNeedsUpgrading && 'first upgrade your database, and then '}update the extension + by disabling and enabling the Wrappers extension. +

+

+ Before reinstalling the wrapper extension, you must first remove all existing wrappers. + Afterward, you can recreate the wrappers. +

+ +
+
+ ) +} + +export const WrapperMissing = ({ bucketName }: { bucketName?: string }) => { + const { data: project } = useSelectedProjectQuery() + const { mutateAsync: createS3VectorsWrapper, isPending: isCreatingS3VectorsWrapper } = + useS3VectorsWrapperCreateMutation() + const { mutateAsync: createSchema, isPending: isCreatingSchema } = useSchemaCreateMutation() + + const onSetupWrapper = async () => { + if (!bucketName) return console.error('Bucket name is required') + try { + await createS3VectorsWrapper({ bucketName }) + await createSchema({ + projectRef: project?.ref, + connectionString: project?.connectionString, + name: getVectorBucketFDWSchemaName(bucketName), + }) + } catch (error) { + toast.error( + `Failed to install wrapper: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + } + + const isLoading = isCreatingS3VectorsWrapper || isCreatingSchema + + return ( + + +

The S3 Vectors Wrapper integration is required in order to query vector tables.

+ +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx new file mode 100644 index 0000000000000..18880a7a35314 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx @@ -0,0 +1,282 @@ +import { BookOpen, ChevronDown, ListPlus } from 'lucide-react' +import Link from 'next/link' +import { useState } from 'react' + +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useParams } from 'common' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { VectorBucketIndex } from 'data/storage/vector-buckets-indexes-query' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { SqlEditor } from 'icons' +import { DOCS_URL } from 'lib/constants' +import { + Button, + cn, + CodeBlock, + Command_Shadcn_, + CommandGroup_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, + Popover_Shadcn_, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + Sheet, + SheetContent, + SheetHeader, + SheetSection, + SheetTitle, + SheetTrigger, +} from 'ui' +import { getVectorBucketFDWSchemaName } from '../VectorBuckets.utils' + +interface VectorBucketTableExamplesSheetProps { + index: VectorBucketIndex +} + +export const VectorBucketTableExamplesSheet = ({ index }: VectorBucketTableExamplesSheetProps) => { + const metadataKeys = index.metadataConfiguration?.nonFilterableMetadataKeys ?? [] + const [language, setLanguage] = useState<'javascript' | 'sql'>('sql') + const [showLanguage, setShowLanguage] = useState(false) + + const updateLanguage = (value: 'javascript' | 'sql') => { + setLanguage(value) + setShowLanguage(false) + } + + return ( + + {/* Move into overflow menu after vectors added */} + + + + +
+ + + Insert vectors into{' '} + {index.indexName} + + + +
+ +
+
+
+
+ ) +} + +const generateDimensionExample = (startValue: number, dimension: number) => { + if (dimension >= 3) { + // For 3+ dimensions, show only first 2 values with ellipsis + return `${startValue.toFixed(1)}, ${(startValue + 0.1).toFixed(1)}, ...` + } else if (dimension === 2) { + // For 2 dimensions, show both values + return `${startValue.toFixed(1)}, ${(startValue + 0.1).toFixed(1)}` + } else { + // For 1 dimension, show single value + return startValue.toFixed(1) + } +} + +const generateDimensionComment = (dimension: number) => { + // Only add comment for 3+ dimensions + if (dimension >= 3) { + return ` // Data should match ${dimension} dimensions` + } + return '' +} + +interface VectorBucketIndexExamplesProps { + bucketName: string + indexName: string + dimension: number + metadataKeys: string[] + language: 'javascript' | 'sql' + onLanguageChange: (value: 'javascript' | 'sql') => void + showLanguage: boolean + onShowLanguageChange: (show: boolean) => void +} + +function VectorBucketIndexExamples({ + bucketName, + indexName, + dimension, + metadataKeys, + language, + onLanguageChange, + showLanguage, + onShowLanguageChange, +}: VectorBucketIndexExamplesProps) { + const { ref: projectRef } = useParams() + + const { can: canReadAPIKeys } = useAsyncCheckPermissions( + PermissionAction.READ, + 'service_api_keys' + ) + const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys }) + const { serviceKey, secretKey } = canReadAPIKeys + ? getKeys(apiKeys) + : { serviceKey: null, secretKey: null } + + const dimensionLabel = `Data should match ${dimension} dimension${dimension > 1 ? 's' : ''}` + const startValue = 0.1 + const dimensionComment = generateDimensionComment(dimension) + const dimensionExample = generateDimensionExample(startValue, dimension) + const sqlComment = dimension >= 3 ? ` -- ${dimensionLabel}` : '' + + const sqlCode = `-- Insert multiple vectors +insert into + "${getVectorBucketFDWSchemaName(bucketName)}"."${indexName}" (key, data, metadata) +values + ( + 'doc-1', + '[${dimensionExample}]'::embd${sqlComment}, + '{${metadataKeys.map((key) => `"${key}": "${key} value"`).join(', ')}}'::jsonb + ), + ( + 'doc-2', + '[${dimensionExample}]'::embd${sqlComment}, + '{${metadataKeys.map((key) => `"${key}": "${key} value"`).join(', ')}}'::jsonb + );` + + const jsCode = `import { createClient } from '@supabase/supabase-js' + +// Adding vector data needs a secret or service role key. +// This code SHOULD NOT be run on the client side, you'll be vulnerable to a data leak. +const client = createClient( + process.env.SUPABASE_URL, + process.env.${secretKey ? 'SUPABASE_SECRET_KEY' : 'SUPABASE_SERVICE_ROLE_KEY'}, +) + +const index = client.storage.vectors + .from('${bucketName}') + .index('${indexName}') + +const result = await index.putVectors({ + vectors: [ + { + key: 'doc-1', + data: { float32: [${dimensionExample}] }${dimensionComment}, + metadata: { ${metadataKeys.map((key) => `${key}: "${key} value"`).join(', ')} }, + }, + { + key: 'doc-2', + data: { float32: [${dimensionExample}] }${dimensionComment}, + metadata: { ${metadataKeys.map((key) => `${key}: "${key} value"`).join(', ')} }, + }, + ], +})` + + return ( + +

+ Use the following code snippet to insert vectors into your table. The{' '} + data property should contain all of your vector + data. +

+
+
+ + +
+ + Language + + +
+
+ + + + + onLanguageChange('sql')} + onClick={() => onLanguageChange('sql')} + > +

SQL

+
+ onLanguageChange('javascript')} + onClick={() => onLanguageChange('javascript')} + > +

JavaScript

+
+
+
+
+
+
+ + +
+ {language === 'javascript' ? ( + + ) : ( + <> + +
+ +
+ + )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/index.tsx similarity index 61% rename from apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails.tsx rename to apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/index.tsx index dea77d4df284a..6b98e92025be8 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/index.tsx @@ -1,12 +1,10 @@ -import { Eye, MoreVertical, Search, Trash2 } from 'lucide-react' +import { MoreVertical, Search, Trash2 } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' -import { useRef, useState } from 'react' -import { toast } from 'sonner' import { parseAsBoolean, useQueryState } from 'nuqs' +import { useRef, useState } from 'react' import { useParams } from 'common' -import { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types' import { ScaffoldContainer, ScaffoldHeader, @@ -15,18 +13,10 @@ import { ScaffoldSectionTitle, } from 'components/layouts/Scaffold' import AlertError from 'components/ui/AlertError' -import { InlineLink } from 'components/ui/InlineLink' -import { DatabaseExtension } from 'data/database-extensions/database-extensions-query' -import { useSchemaCreateMutation } from 'data/database/schema-create-mutation' -import { useS3VectorsWrapperCreateMutation } from 'data/storage/s3-vectors-wrapper-create-mutation' import { useVectorBucketQuery } from 'data/storage/vector-bucket-query' -import { - useVectorBucketsIndexesQuery, - VectorBucketIndex, -} from 'data/storage/vector-buckets-indexes-query' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useVectorBucketsIndexesQuery } from 'data/storage/vector-buckets-indexes-query' import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect' -import { DOCS_URL } from 'lib/constants' +import { SqlEditor, TableEditor } from 'icons' import { Button, Card, @@ -44,14 +34,19 @@ import { } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import { Admonition } from 'ui-patterns/admonition' -import { CreateVectorTableSheet } from './CreateVectorTableSheet' -import { DeleteVectorBucketModal } from './DeleteVectorBucketModal' -import { DeleteVectorTableModal } from './DeleteVectorTableModal' -import { getVectorBucketFDWSchemaName } from './VectorBuckets.utils' -import { useS3VectorsWrapperExtension } from './useS3VectorsWrapper' -import { useS3VectorsWrapperInstance } from './useS3VectorsWrapperInstance' -import { useSelectedVectorBucket } from './useSelectedVectorBuckets' +import { CreateVectorTableSheet } from '../CreateVectorTableSheet' +import { DeleteVectorBucketModal } from '../DeleteVectorBucketModal' +import { DeleteVectorTableModal } from '../DeleteVectorTableModal' +import { getVectorBucketFDWSchemaName } from '../VectorBuckets.utils' +import { useS3VectorsWrapperExtension } from '../useS3VectorsWrapper' +import { useS3VectorsWrapperInstance } from '../useS3VectorsWrapperInstance' +import { useSelectedVectorBucket } from '../useSelectedVectorBuckets' +import { + ExtensionNeedsUpgrade, + ExtensionNotInstalled, + WrapperMissing, +} from './VectorBucketCallouts' +import { VectorBucketTableExamplesSheet } from './VectorBucketTableExamplesSheet' export const VectorBucketDetails = () => { const router = useRouter() @@ -150,7 +145,6 @@ export const VectorBucketDetails = () => { {state === 'not-installed' && ( { )} {state === 'needs-upgrade' && ( {
- {wrapperInstance ? ( - - ) : null} + - - - ) -} - -const ExtensionNeedsUpgrade = ({ - bucketName, - projectRef, - wrapperMeta, - wrappersExtension, -}: { - bucketName?: string - projectRef: string - wrapperMeta: WrapperMeta - wrappersExtension: DatabaseExtension -}) => { - // [Joshen] Default version is what's on the DB, so if the installed version is already the default version - // but still doesnt meet the minimum extension version, then DB upgrade is required - const databaseNeedsUpgrading = - wrappersExtension?.installed_version === wrappersExtension?.default_version - - return ( - - -

- The {wrapperMeta.label} wrapper requires a minimum extension version of{' '} - {wrapperMeta.minimumExtensionVersion}. You have version{' '} - {wrappersExtension?.installed_version} installed. Please{' '} - {databaseNeedsUpgrading && 'first upgrade your database, and then '}update the extension - by disabling and enabling the Wrappers extension. -

-

- Before reinstalling the wrapper extension, you must first remove all existing wrappers. - Afterward, you can recreate the wrappers. -

- -
-
- ) -} - -const WrapperMissing = ({ bucketName }: { bucketName?: string }) => { - const { data: project } = useSelectedProjectQuery() - const { mutateAsync: createS3VectorsWrapper, isPending: isCreatingS3VectorsWrapper } = - useS3VectorsWrapperCreateMutation() - const { mutateAsync: createSchema, isPending: isCreatingSchema } = useSchemaCreateMutation() - - const onSetupWrapper = async () => { - if (!bucketName) return console.error('Bucket name is required') - try { - await createS3VectorsWrapper({ bucketName }) - await createSchema({ - projectRef: project?.ref, - connectionString: project?.connectionString, - name: getVectorBucketFDWSchemaName(bucketName), - }) - } catch (error) { - toast.error( - `Failed to install wrapper: ${error instanceof Error ? error.message : 'Unknown error'}` - ) - } - } - - const isLoading = isCreatingS3VectorsWrapper || isCreatingSchema - - return ( - - -

The S3 Vectors Wrapper integration is required in order to query vector tables.

- -
-
- ) -} diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/index.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/index.tsx index 8f58f36db9e89..5266d18753227 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/index.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/index.tsx @@ -1,5 +1,6 @@ import { ChevronRight, Search } from 'lucide-react' import { useRouter } from 'next/navigation' +import { parseAsBoolean, useQueryState } from 'nuqs' import { useState, type KeyboardEvent, type MouseEvent } from 'react' import { useParams } from 'common' @@ -14,7 +15,7 @@ import { Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } f import { Input } from 'ui-patterns/DataInputs/Input' import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { EmptyBucketState } from '../EmptyBucketState' -import { CreateVectorBucketDialog } from './CreateVectorBucketDialog' +import { CreateVectorBucketButton, CreateVectorBucketDialog } from './CreateVectorBucketDialog' /** * [Joshen] Low-priority refactor: We should use a virtualized table here as per how we do it @@ -26,6 +27,10 @@ export const VectorsBuckets = () => { const router = useRouter() const [filterString, setFilterString] = useState('') + const [visible, setVisible] = useQueryState( + 'new', + parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) + ) const { data, @@ -68,7 +73,7 @@ export const VectorsBuckets = () => { {isSuccessBuckets && ( <> {bucketsList.length === 0 ? ( - + setVisible(true)} /> ) : (
@@ -83,7 +88,8 @@ export const VectorsBuckets = () => { onChange={(e) => setFilterString(e.target.value)} icon={} /> - + + setVisible(true)} />
{isLoadingBuckets ? ( @@ -165,6 +171,7 @@ export const VectorsBuckets = () => { )} )} + ) } From 9d66964b629160b34f7375ce21482cc1782f4653 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 27 Nov 2025 22:27:19 +1000 Subject: [PATCH 02/14] table create performance (#40857) * table create performance * test * Clean up + refactor * Nit housekeeping * Minor housekeeping again * Fix * Fixy --------- Co-authored-by: Joshen Lim --- .../formatter/ForeignKeyFormatter.tsx | 29 +- .../Wrappers/WrapperTableEditor.tsx | 4 +- .../Storage/ImportForeignSchemaDialog.tsx | 2 +- .../SidePanelEditor/ActionBar.tsx | 3 +- .../ColumnEditor/ColumnEditor.tsx | 6 +- .../ForeignKeySelector/ForeignKeySelector.tsx | 27 +- .../ForeignRowSelector/ForeignRowSelector.tsx | 6 +- .../SidePanelEditor/RowEditor/HeaderTitle.tsx | 4 +- .../SidePanelEditor/RowEditor/InputField.tsx | 4 +- .../RowEditor/JsonEditor/index.tsx | 2 +- .../SidePanelEditor/RowEditor/RowEditor.tsx | 37 +- .../SidePanelEditor/RowEditor/TextEditor.tsx | 2 +- .../SidePanelEditor/SchemaEditor.tsx | 4 +- .../SidePanelEditor/SidePanelEditor.tsx | 10 +- .../SidePanelEditor.utils.createTable.test.ts | 518 ++++++++++++++++++ .../SidePanelEditor/SidePanelEditor.utils.tsx | 413 +++++++------- .../SpreadsheetImport/SpreadsheetImport.tsx | 6 +- .../TableEditor/HeaderTitle.tsx | 4 +- .../TableEditor/TableEditor.tsx | 11 +- .../data/tables/table-retrieve-query.ts | 26 +- 20 files changed, 837 insertions(+), 281 deletions(-) create mode 100644 apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts diff --git a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx index c960551a749c1..738d3605fbd57 100644 --- a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx +++ b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx @@ -8,7 +8,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useTableEditorQuery } from 'data/table-editor/table-editor-query' import { isTableLike } from 'data/table-editor/table-editor-types' -import { useTablesQuery as useTableRetrieveQuery } from 'data/tables/table-retrieve-query' +import { useTableQuery } from 'data/tables/table-retrieve-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_ } from 'ui' import type { SupaRow } from '../../types' @@ -38,21 +38,18 @@ export const ForeignKeyFormatter = (props: Props) => { r.source_column_name === column.name ) - const { data: targetTable, isLoading: isLoadingTargetTable } = - useTableRetrieveQuery( - { - projectRef: project?.ref, - connectionString: project?.connectionString, - schema: relationship?.target_table_schema ?? '', - name: relationship?.target_table_name ?? '', - }, - { - enabled: - !!project?.ref && - !!relationship?.target_table_schema && - !!relationship?.target_table_name, - } - ) + const { data: targetTable, isLoading: isLoadingTargetTable } = useTableQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: relationship?.target_table_schema ?? '', + name: relationship?.target_table_name ?? '', + }, + { + enabled: + !!project?.ref && !!relationship?.target_table_schema && !!relationship?.target_table_name, + } + ) const value = row[column.key] const formattedValue = diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTableEditor.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTableEditor.tsx index 92212d51b5fd4..52f627617956c 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTableEditor.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTableEditor.tsx @@ -1,9 +1,10 @@ import { Check, ChevronsUpDown, Database, Plus } from 'lucide-react' import { useEffect, useState } from 'react' -import ActionBar from 'components/interfaces/TableGridEditor/SidePanelEditor/ActionBar' +import { ActionBar } from 'components/interfaces/TableGridEditor/SidePanelEditor/ActionBar' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useSchemasQuery } from 'data/database/schemas-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, cn, @@ -27,7 +28,6 @@ import { import WrapperDynamicColumns from './WrapperDynamicColumns' import type { Table, TableOption } from './Wrappers.types' import { makeValidateRequired } from './Wrappers.utils' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' export type WrapperTableEditorProps = { visible: boolean diff --git a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx index 7d982581e39cd..4aa8e54520cd3 100644 --- a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx +++ b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx @@ -15,7 +15,7 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, Form_Shadcn_, FormField_Shadcn_, Input_Shadcn_, Modal, Separator } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { formatWrapperTables } from '../Integrations/Wrappers/Wrappers.utils' -import SchemaEditor from '../TableGridEditor/SidePanelEditor/SchemaEditor' +import { SchemaEditor } from '../TableGridEditor/SidePanelEditor/SchemaEditor' import { getAnalyticsBucketFDWServerName } from './AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.utils' import { useAnalyticsBucketAssociatedEntities } from './AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities' import { getDecryptedParameters } from './ImportForeignSchemaDialog.utils' diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx index 67cd2bdb999a4..fd66522f00c38 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx @@ -13,7 +13,7 @@ interface ActionBarProps { formId?: string } -const ActionBar = ({ +export const ActionBar = ({ loading = false, disableApply = false, hideApply = false, @@ -70,4 +70,3 @@ const ActionBar = ({
) } -export default ActionBar diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx index 06a6c3f27d80f..462ad22f131e3 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx @@ -21,7 +21,7 @@ import { useProtectedSchemas } from 'hooks/useProtectedSchemas' import { DOCS_URL } from 'lib/constants' import type { Dictionary } from 'types' import { Button, Checkbox, Input, SidePanel, Toggle } from 'ui' -import ActionBar from '../ActionBar' +import { ActionBar } from '../ActionBar' import type { ForeignKey } from '../ForeignKeySelector/ForeignKeySelector.types' import { formatForeignKeys } from '../ForeignKeySelector/ForeignKeySelector.utils' import { TEXT_TYPES } from '../SidePanelEditor.constants' @@ -62,7 +62,7 @@ export interface ColumnEditorProps { updateEditorDirty: () => void } -const ColumnEditor = ({ +export const ColumnEditor = ({ column, selectedTable, visible = false, @@ -373,5 +373,3 @@ const ColumnEditor = ({ ) } - -export default ColumnEditor diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx index c7bb451b9df63..273e3315df8f1 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx @@ -15,13 +15,13 @@ import { DocsButton } from 'components/ui/DocsButton' import InformationBox from 'components/ui/InformationBox' import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants' import { useSchemasQuery } from 'data/database/schemas-query' -import { useTablesQuery as useTableRetrieveQuery } from 'data/tables/table-retrieve-query' +import { useTableQuery } from 'data/tables/table-retrieve-query' import { useTablesQuery } from 'data/tables/tables-query' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL } from 'lib/constants' import { uuidv4 } from 'lib/helpers' -import ActionBar from '../ActionBar' +import { ActionBar } from '../ActionBar' import { NUMERICAL_TYPES, TEXT_TYPES } from '../SidePanelEditor.constants' import type { ColumnField } from '../SidePanelEditor.types' import { FOREIGN_KEY_CASCADE_OPTIONS } from './ForeignKeySelector.constants' @@ -77,18 +77,17 @@ export const ForeignKeySelector = ({ includeColumns: false, }) - const { data: selectedTable, isLoading: isLoadingSelectedTable } = - useTableRetrieveQuery( - { - projectRef: project?.ref, - connectionString: project?.connectionString, - schema: fk.schema, - name: fk.table, - }, - { - enabled: !!project?.ref && !!fk.schema && !!fk.table, - } - ) + const { data: selectedTable, isLoading: isLoadingSelectedTable } = useTableQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: fk.schema, + name: fk.table, + }, + { + enabled: !!project?.ref && !!fk.schema && !!fk.table, + } + ) const disableApply = isLoadingSelectedTable || selectedTable === undefined || hasTypeErrors diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx index b5ea64cd8686b..ab07199af1d7e 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx @@ -23,7 +23,7 @@ import { } from 'state/role-impersonation-state' import { TableEditorTableStateContextProvider } from 'state/table-editor-table' import { Button, SidePanel } from 'ui' -import ActionBar from '../../ActionBar' +import { ActionBar } from '../../ActionBar' import { ForeignKey } from '../../ForeignKeySelector/ForeignKeySelector.types' import { convertByteaToHex } from '../RowEditor.utils' import Pagination from './Pagination' @@ -38,7 +38,7 @@ export interface ForeignRowSelectorProps { closePanel: () => void } -const ForeignRowSelector = ({ +export const ForeignRowSelector = ({ visible, foreignKey, onSelect, @@ -269,5 +269,3 @@ const ForeignRowSelector = ({ ) } - -export default ForeignRowSelector diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/HeaderTitle.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/HeaderTitle.tsx index cfa43f090fd2c..4bbc0b5f3eeb8 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/HeaderTitle.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/HeaderTitle.tsx @@ -3,7 +3,7 @@ interface HeaderTitleProps { tableName?: string } -const HeaderTitle = ({ isNewRecord, tableName }: HeaderTitleProps) => { +export const HeaderTitle = ({ isNewRecord, tableName }: HeaderTitleProps) => { let header = `${isNewRecord ? 'Add new' : 'Update'} row ${isNewRecord ? 'to' : 'from'} ` return ( @@ -13,5 +13,3 @@ const HeaderTitle = ({ isNewRecord, tableName }: HeaderTitleProps) => { ) } - -export default HeaderTitle diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx index 1c13e580b23f4..7c72debe95d03 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx @@ -35,7 +35,7 @@ export interface InputFieldProps { onSelectForeignKey?: () => void } -const InputField = ({ +export const InputField = ({ field, errors, isEditable = true, @@ -353,5 +353,3 @@ const InputField = ({ /> ) } - -export default InputField diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/index.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/index.tsx index ca0f3aa75765d..f591d6e602805 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/index.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/index.tsx @@ -12,7 +12,7 @@ import { useGetCellValueMutation } from 'data/table-rows/get-cell-value-mutation import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { minifyJSON, prettifyJSON, removeJSONTrailingComma, tryParseJson } from 'lib/helpers' import { Button, SidePanel, cn } from 'ui' -import ActionBar from '../../ActionBar' +import { ActionBar } from '../../ActionBar' import { isValueTruncated } from '../RowEditor.utils' import { DrilldownViewer } from './DrilldownViewer/DrilldownViewer' import { JsonCodeEditor } from './JsonCodeEditor' diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx index 935915f6e7e8e..e8f74f4671867 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx @@ -6,11 +6,11 @@ import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constra import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import type { Dictionary } from 'types' import { SidePanel } from 'ui' -import ActionBar from '../ActionBar' +import { ActionBar } from '../ActionBar' import { formatForeignKeys } from '../ForeignKeySelector/ForeignKeySelector.utils' -import ForeignRowSelector from './ForeignRowSelector/ForeignRowSelector' -import HeaderTitle from './HeaderTitle' -import InputField from './InputField' +import { ForeignRowSelector } from './ForeignRowSelector/ForeignRowSelector' +import { HeaderTitle } from './HeaderTitle' +import { InputField } from './InputField' import { JsonEditor } from './JsonEditor' import type { EditValue, RowField } from './RowEditor.types' import { @@ -32,7 +32,9 @@ export interface RowEditorProps { updateEditorDirty: () => void } -const RowEditor = ({ +const formId = 'row-editor-panel' + +export const RowEditor = ({ row, selectedTable, visible = false, @@ -158,7 +160,7 @@ const RowEditor = ({ return ( + } > -
onSaveChanges(e)} className="h-full"> + onSaveChanges(e)} className="h-full">
{requiredFields.length > 0 && ( @@ -244,15 +256,6 @@ const RowEditor = ({ readOnly={!editable} />
-
- -
@@ -269,5 +272,3 @@ const RowEditor = ({
) } - -export default RowEditor diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/TextEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/TextEditor.tsx index 2915aaf4dcf33..7191536347b44 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/TextEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/TextEditor.tsx @@ -13,7 +13,7 @@ import { isTableLike } from 'data/table-editor/table-editor-types' import { useGetCellValueMutation } from 'data/table-rows/get-cell-value-mutation' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, SidePanel, cn } from 'ui' -import ActionBar from '../ActionBar' +import { ActionBar } from '../ActionBar' import { isValueTruncated } from './RowEditor.utils' interface TextEditorProps { diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SchemaEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SchemaEditor.tsx index bc4d2fbb696a8..0b5a06fedc68b 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SchemaEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SchemaEditor.tsx @@ -11,7 +11,7 @@ interface SchemaEditorProps { closePanel: () => void } -const SchemaEditor = ({ visible, onSuccess, closePanel }: SchemaEditorProps) => { +export const SchemaEditor = ({ visible, onSuccess, closePanel }: SchemaEditorProps) => { const { data: project } = useSelectedProjectQuery() const [errors, setErrors] = useState<{ name?: string }>({ name: undefined }) @@ -77,5 +77,3 @@ const SchemaEditor = ({ visible, onSuccess, closePanel }: SchemaEditorProps) => ) } - -export default SchemaEditor diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index b13becd288923..12d2f525bc24c 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -31,15 +31,15 @@ import { useTableEditorStateSnapshot } from 'state/table-editor' import { createTabId, useTabsStateSnapshot } from 'state/tabs' import type { Dictionary } from 'types' import { SonnerProgress } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import ColumnEditor from './ColumnEditor/ColumnEditor' +import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' +import { ColumnEditor } from './ColumnEditor/ColumnEditor' import type { ForeignKey } from './ForeignKeySelector/ForeignKeySelector.types' -import ForeignRowSelector from './RowEditor/ForeignRowSelector/ForeignRowSelector' +import { ForeignRowSelector } from './RowEditor/ForeignRowSelector/ForeignRowSelector' import { JsonEditor } from './RowEditor/JsonEditor' -import RowEditor from './RowEditor/RowEditor' +import { RowEditor } from './RowEditor/RowEditor' import { convertByteaToHex } from './RowEditor/RowEditor.utils' import { TextEditor } from './RowEditor/TextEditor' -import SchemaEditor from './SchemaEditor' +import { SchemaEditor } from './SchemaEditor' import type { ColumnField, CreateColumnPayload, UpdateColumnPayload } from './SidePanelEditor.types' import { createColumn, diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts new file mode 100644 index 0000000000000..9041b931c925c --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts @@ -0,0 +1,518 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants' +import type { ForeignKey } from './ForeignKeySelector/ForeignKeySelector.types' +import type { ColumnField } from './SidePanelEditor.types' + +// Define mock functions at module level +const mockExecuteSql = vi.fn() +const mockGetTable = vi.fn() +const mockSendEvent = vi.fn() +const mockPrefetchEditorTablePage = vi.fn() +const mockToastLoading = vi.fn() +const mockToastSuccess = vi.fn() +const mockToastError = vi.fn() +const mockFetchQuery = vi.fn() + +// Setup mocks before imports +vi.mock('data/query-client', () => ({ + getQueryClient: () => ({ + fetchQuery: mockFetchQuery, + }), +})) + +vi.mock('data/sql/execute-sql-query', () => ({ + executeSql: (...args: unknown[]) => mockExecuteSql(...args), +})) + +vi.mock('data/tables/table-retrieve-query', () => ({ + getTable: (...args: unknown[]) => mockGetTable(...args), + getTableQuery: (...args: unknown[]) => mockGetTable(...args), +})) + +vi.mock('data/telemetry/send-event-mutation', () => ({ + sendEvent: (...args: unknown[]) => mockSendEvent(...args), +})) + +vi.mock('data/prefetchers/project.$ref.editor.$id', () => ({ + prefetchEditorTablePage: (...args: unknown[]) => mockPrefetchEditorTablePage(...args), +})) + +vi.mock('sonner', () => ({ + toast: { + loading: (...args: unknown[]) => mockToastLoading(...args), + success: (...args: unknown[]) => mockToastSuccess(...args), + error: (...args: unknown[]) => mockToastError(...args), + }, +})) + +// Mock SparkBar component used in toast +vi.mock('components/ui/SparkBar', () => ({ + default: () => null, +})) + +// Import after mocks are set up +import { createTable } from './SidePanelEditor.utils' + +// Helper to create a column field with defaults +const createColumnField = (overrides: Partial = {}): ColumnField => ({ + id: 'col-1', + name: 'column', + table: 'test_table', + schema: 'public', + format: 'text', + check: null, + comment: null, + defaultValue: null, + isNullable: true, + isUnique: false, + isArray: false, + isIdentity: false, + isPrimaryKey: false, + isNewColumn: true, + isEncrypted: false, + ...overrides, +}) + +describe('createTable', () => { + const projectRef = 'test-project-ref' + const connectionString = 'postgresql://localhost:5432/test' + const toastId = 'test-toast-id' + + const basePayload = { + name: 'test_table', + schema: 'public', + comment: 'A test table', + } + + const mockTableResult = { + id: 123, + name: 'test_table', + schema: 'public', + comment: 'A test table', + columns: [], + primary_keys: [], + relationships: [], + } + + beforeEach(() => { + vi.clearAllMocks() + + // Default mock implementations + mockExecuteSql.mockResolvedValue({ result: [] }) + mockGetTable.mockResolvedValue(mockTableResult) + mockSendEvent.mockResolvedValue({}) + mockPrefetchEditorTablePage.mockResolvedValue(undefined) + mockFetchQuery.mockImplementation(({ queryFn }) => { + if (queryFn) { + return queryFn({ signal: new AbortController().signal }) + } + return Promise.resolve(mockTableResult) + }) + }) + + it('should create a basic table with no columns', async () => { + const result = await createTable({ + projectRef, + connectionString, + toastId, + payload: basePayload, + columns: [], + foreignKeyRelations: [], + isRLSEnabled: false, + }) + + expect(mockExecuteSql).toHaveBeenCalledTimes(1) + expect(mockExecuteSql).toHaveBeenCalledWith( + expect.objectContaining({ + projectRef, + connectionString, + queryKey: ['table', 'create-with-columns'], + }) + ) + + // Should show loading toast + expect(mockToastLoading).toHaveBeenCalledWith(`Creating table ${basePayload.name}...`, { + id: toastId, + }) + + // Should track table creation event + expect(mockSendEvent).toHaveBeenCalledWith({ + event: { + action: 'table_created', + properties: { + method: 'table_editor', + schema_name: 'public', + table_name: 'test_table', + }, + groups: { + project: projectRef, + }, + }, + }) + + // Should prefetch the editor table page + expect(mockPrefetchEditorTablePage).toHaveBeenCalledWith( + expect.objectContaining({ + projectRef, + connectionString, + id: mockTableResult.id, + }) + ) + + expect(result).toStrictEqual(mockTableResult) + }) + + it('should create a table with RLS enabled', async () => { + await createTable({ + projectRef, + connectionString, + toastId, + payload: basePayload, + columns: [], + foreignKeyRelations: [], + isRLSEnabled: true, + }) + + const sqlCall = mockExecuteSql.mock.calls[0][0] + expect(sqlCall.sql).toContain('ENABLE ROW LEVEL SECURITY') + + expect(mockSendEvent).toHaveBeenCalledWith({ + event: { + action: 'table_rls_enabled', + properties: { + method: 'table_editor', + schema_name: 'public', + table_name: 'test_table', + }, + groups: { + project: projectRef, + }, + }, + }) + }) + + it('should create a table with columns', async () => { + const columns: ColumnField[] = [ + createColumnField({ + id: 'col-1', + name: 'id', + format: 'int8', + isNullable: false, + isIdentity: true, + isPrimaryKey: true, + }), + createColumnField({ + id: 'col-2', + name: 'name', + format: 'text', + comment: 'User name', + }), + ] + + await createTable({ + projectRef, + connectionString, + toastId, + payload: basePayload, + columns, + foreignKeyRelations: [], + isRLSEnabled: false, + }) + + const sqlCall = mockExecuteSql.mock.calls[0][0] + expect(sqlCall.sql).toContain('ALTER TABLE') + expect(sqlCall.sql).toContain('ADD COLUMN') + expect(sqlCall.sql).toContain('ADD PRIMARY KEY') + expect(sqlCall.sql).toContain('id') + }) + + it('should create a table with composite primary key', async () => { + const columns: ColumnField[] = [ + createColumnField({ + id: 'col-1', + name: 'user_id', + format: 'int8', + isNullable: false, + isPrimaryKey: true, + }), + createColumnField({ + id: 'col-2', + name: 'order_id', + format: 'int8', + isNullable: false, + isPrimaryKey: true, + }), + ] + + await createTable({ + projectRef, + connectionString, + toastId, + payload: basePayload, + columns, + foreignKeyRelations: [], + isRLSEnabled: false, + }) + + const sqlCall = mockExecuteSql.mock.calls[0][0] + expect(sqlCall.sql).toContain('ADD PRIMARY KEY') + expect(sqlCall.sql).toContain('user_id') + expect(sqlCall.sql).toContain('order_id') + }) + + it('should create a table with foreign key relations', async () => { + const columns: ColumnField[] = [ + createColumnField({ + id: 'col-1', + name: 'id', + format: 'int8', + isNullable: false, + isIdentity: true, + isPrimaryKey: true, + }), + createColumnField({ + id: 'col-2', + name: 'user_id', + format: 'int8', + isNullable: false, + }), + ] + + const foreignKeyRelations: ForeignKey[] = [ + { + schema: 'public', + table: 'users', + columns: [{ source: 'user_id', target: 'id' }], + deletionAction: FOREIGN_KEY_CASCADE_ACTION.CASCADE, + updateAction: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, + }, + ] + + await createTable({ + projectRef, + connectionString, + toastId, + payload: basePayload, + columns, + foreignKeyRelations, + isRLSEnabled: false, + }) + + const sqlCall = mockExecuteSql.mock.calls[0][0] + expect(sqlCall.sql).toContain('ADD FOREIGN KEY') + expect(sqlCall.sql).toContain('REFERENCES') + expect(sqlCall.sql).toContain('"users"') + expect(sqlCall.sql).toContain('ON DELETE CASCADE') + }) + + it('should include organization slug in telemetry when provided', async () => { + const organizationSlug = 'test-org' + + await createTable({ + projectRef, + connectionString, + toastId, + payload: basePayload, + columns: [], + foreignKeyRelations: [], + isRLSEnabled: true, + organizationSlug, + }) + + expect(mockSendEvent).toHaveBeenCalledWith({ + event: expect.objectContaining({ + action: 'table_created', + groups: { + project: projectRef, + organization: organizationSlug, + }, + }), + }) + + expect(mockSendEvent).toHaveBeenCalledWith({ + event: expect.objectContaining({ + action: 'table_rls_enabled', + groups: { + project: projectRef, + organization: organizationSlug, + }, + }), + }) + }) + + it('should handle telemetry errors gracefully', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + mockSendEvent.mockRejectedValue(new Error('Telemetry failed')) + + const result = await createTable({ + projectRef, + connectionString, + toastId, + payload: basePayload, + columns: [], + foreignKeyRelations: [], + isRLSEnabled: false, + }) + + expect(result).toStrictEqual(mockTableResult) + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to track table creation event:', + expect.any(Error) + ) + + consoleErrorSpy.mockRestore() + }) + + it('should create a table with nullable connectionString', async () => { + await createTable({ + projectRef, + connectionString: null, + toastId, + payload: basePayload, + columns: [], + foreignKeyRelations: [], + isRLSEnabled: false, + }) + + expect(mockExecuteSql).toHaveBeenCalledWith( + expect.objectContaining({ + projectRef, + connectionString: null, + }) + ) + }) + + it('should create table with column having default value', async () => { + const columns: ColumnField[] = [ + createColumnField({ + name: 'status', + defaultValue: "'pending'", + isNullable: false, + }), + ] + + await createTable({ + projectRef, + connectionString, + toastId, + payload: basePayload, + columns, + foreignKeyRelations: [], + isRLSEnabled: false, + }) + + expect(mockExecuteSql).toHaveBeenCalledTimes(1) + }) + + it('should create table with unique column', async () => { + const columns: ColumnField[] = [ + createColumnField({ + name: 'email', + isNullable: false, + isUnique: true, + }), + ] + + await createTable({ + projectRef, + connectionString, + toastId, + payload: basePayload, + columns, + foreignKeyRelations: [], + isRLSEnabled: false, + }) + + expect(mockExecuteSql).toHaveBeenCalledTimes(1) + }) + + it('should create table with array column', async () => { + const columns: ColumnField[] = [ + createColumnField({ + name: 'tags', + isArray: true, + }), + ] + + await createTable({ + projectRef, + connectionString, + toastId, + payload: basePayload, + columns, + foreignKeyRelations: [], + isRLSEnabled: false, + }) + + expect(mockExecuteSql).toHaveBeenCalledTimes(1) + }) + + it('should create table with check constraint', async () => { + const columns: ColumnField[] = [ + createColumnField({ + name: 'age', + format: 'int4', + check: 'age >= 0', + isNullable: false, + }), + ] + + await createTable({ + projectRef, + connectionString, + toastId, + payload: basePayload, + columns, + foreignKeyRelations: [], + isRLSEnabled: false, + }) + + expect(mockExecuteSql).toHaveBeenCalledTimes(1) + }) + + it('should propagate SQL execution errors', async () => { + mockExecuteSql.mockRejectedValue(new Error('SQL execution failed')) + + await expect( + createTable({ + projectRef, + connectionString, + toastId, + payload: basePayload, + columns: [], + foreignKeyRelations: [], + isRLSEnabled: false, + }) + ).rejects.toThrow('SQL execution failed') + }) + + it('should create table in non-public schema', async () => { + const customSchemaPayload = { + name: 'custom_table', + schema: 'private', + comment: 'A private table', + } + + await createTable({ + projectRef, + connectionString, + toastId, + payload: customSchemaPayload, + columns: [], + foreignKeyRelations: [], + isRLSEnabled: false, + }) + + const sqlCall = mockExecuteSql.mock.calls[0][0] + expect(sqlCall.sql).toMatch(/private\.custom_table|"private"\."custom_table"/) + + expect(mockSendEvent).toHaveBeenCalledWith({ + event: expect.objectContaining({ + properties: expect.objectContaining({ + schema_name: 'private', + }), + }), + }) + }) +}) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx index 9fb9ab70ea799..e1d7821eb7395 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx @@ -1,3 +1,4 @@ +import pgMeta from '@supabase/pg-meta' import type { PostgresPrimaryKey } from '@supabase/postgres-meta' import { chunk, find, isEmpty, isEqual } from 'lodash' import Papa from 'papaparse' @@ -22,10 +23,9 @@ import { prefetchTableEditor } from 'data/table-editor/table-editor-query' import { tableRowKeys } from 'data/table-rows/keys' import { executeWithRetry } from 'data/table-rows/table-rows-query' import { tableKeys } from 'data/tables/keys' -import { createTable as createTableMutation } from 'data/tables/table-create-mutation' -import { deleteTable as deleteTableMutation } from 'data/tables/table-delete-mutation' import { getTable, + getTableQuery, RetrievedTableColumn, RetrieveTableResult, } from 'data/tables/table-retrieve-query' @@ -52,15 +52,27 @@ const CHUNK_SIZE = 1024 * 1024 * 0.1 // 0.1MB * The functions below are basically just queries but may be supported directly * from the pg-meta library in the future */ -export const addPrimaryKey = async ( +const getAddPrimaryKeySQL = ({ + schema, + table, + columns, +}: { + schema: string + table: string + columns: string[] +}) => { + const primaryKeyColumns = columns.join('","') + return `ALTER TABLE "${schema}"."${table}" ADD PRIMARY KEY (${primaryKeyColumns})` +} + +const addPrimaryKey = async ( projectRef: string, connectionString: string | undefined | null, schema: string, table: string, columns: string[] ) => { - const primaryKeyColumns = columns.join('","') - const query = `ALTER TABLE "${schema}"."${table}" ADD PRIMARY KEY ("${primaryKeyColumns}")` + const query = getAddPrimaryKeySQL({ schema, table, columns }) return await executeSql({ projectRef: projectRef, connectionString: connectionString, @@ -69,7 +81,7 @@ export const addPrimaryKey = async ( }) } -export const dropConstraint = async ( +const dropConstraint = async ( projectRef: string, connectionString: string | undefined | null, schema: string, @@ -85,7 +97,7 @@ export const dropConstraint = async ( }) } -export const getAddForeignKeySQL = ({ +const getAddForeignKeySQL = ({ table, foreignKeys, }: { @@ -128,7 +140,7 @@ export const getAddForeignKeySQL = ({ ) } -export const addForeignKey = async ({ +const addForeignKey = async ({ projectRef, connectionString, table, @@ -148,7 +160,7 @@ export const addForeignKey = async ({ }) } -export const getRemoveForeignKeySQL = ({ +const getRemoveForeignKeySQL = ({ table, foreignKeys, }: { @@ -169,7 +181,7 @@ DROP CONSTRAINT IF EXISTS "${relation.name}" ) } -export const removeForeignKey = async ({ +const removeForeignKey = async ({ projectRef, connectionString, table, @@ -189,7 +201,7 @@ export const removeForeignKey = async ({ }) } -export const updateForeignKey = async ({ +const updateForeignKey = async ({ projectRef, connectionString, table, @@ -214,10 +226,28 @@ export const updateForeignKey = async ({ }) } +const getUpdateIdentitySequenceSQL = ({ + schema, + table, + column, +}: { + schema: string + table: string + column: string +}) => { + return `SELECT setval('"${schema}"."${table}_${column}_seq"', (SELECT COALESCE(MAX("${column}"), 1) FROM "${schema}"."${table}"))` +} + +const getEnableRLSSQL = ({ schema, table }: { schema: string; table: string }) => { + return `ALTER TABLE "${schema}"."${table}" ENABLE ROW LEVEL SECURITY` +} + /** * The methods below involve several contexts due to the UI flow of the * dashboard and hence do not sit within their own stores */ + +/** TODO: Refactor to do in a single transaction */ export const createColumn = async ({ projectRef, connectionString, @@ -293,6 +323,7 @@ export const createColumn = async ({ } } +/** TODO: Refactor to do in a single transaction */ export const updateColumn = async ({ projectRef, connectionString, @@ -372,6 +403,7 @@ export const updateColumn = async ({ } } +/** TODO: Refactor to do in a single transaction */ export const duplicateTable = async ( projectRef: string, connectionString: string | undefined | null, @@ -481,18 +513,99 @@ export const createTable = async ({ }) => { const queryClient = getQueryClient() - // Create the table first. Error may be thrown. - await createTableMutation({ - projectRef: projectRef, - connectionString: connectionString, - payload: payload, + // Build all SQL statements for table creation, columns, and constraints + // to execute in a single transaction for better performance and atomicity + const sqlStatements: string[] = [] + + // 1. Create table SQL + const { sql: createTableSql } = pgMeta.tables.create(payload) + sqlStatements.push(createTableSql) + + // 2. Enable RLS if configured + if (isRLSEnabled) { + const enableRLSSQL = getEnableRLSSQL({ schema: payload.schema, table: payload.name }) + sqlStatements.push(enableRLSSQL) + } + + // 3. Add columns SQL (without primary keys - those are added as constraints) + for (const column of columns) { + const columnPayload = generateCreateColumnPayload( + { schema: payload.schema, name: payload.name } as RetrieveTableResult, + { ...column, isPrimaryKey: false } + ) + const { sql: columnSQL } = pgMeta.columns.create({ + schema: columnPayload.schema, + table: columnPayload.table, + name: columnPayload.name, + type: columnPayload.type, + default_value: columnPayload.defaultValue, + default_value_format: columnPayload.defaultValueFormat, + is_identity: columnPayload.isIdentity, + is_nullable: columnPayload.isNullable, + is_primary_key: columnPayload.isPrimaryKey, + is_unique: columnPayload.isUnique, + comment: columnPayload.comment, + check: columnPayload.check, + }) + sqlStatements.push(columnSQL) + } + + // 4. Add primary key constraint (supports composite keys) + const primaryKeyColumns = columns + .filter((column) => column.isPrimaryKey) + .map((column) => column.name) + if (primaryKeyColumns.length > 0) { + const primaryKeySQL = getAddPrimaryKeySQL({ + schema: payload.schema, + table: payload.name, + columns: primaryKeyColumns, + }) + sqlStatements.push(primaryKeySQL) + } + + // 5. Add foreign key constraints + if (foreignKeyRelations.length > 0) { + const fkSql = getAddForeignKeySQL({ + table: { schema: payload.schema, name: payload.name }, + foreignKeys: foreignKeyRelations, + }) + // Remove trailing semicolon since we join with semicolons + sqlStatements.push(fkSql.replace(/;+$/, '')) + } + + // Execute all table creation SQL in a single transaction + toast.loading(`Creating table ${payload.name}...`, { id: toastId }) + + await executeSql({ + projectRef, + connectionString, + sql: sqlStatements.join(';\n'), + queryKey: ['table', 'create-with-columns'], }) - // Track table creation event - try { - await sendEvent({ + // Track table creation event (fire-and-forget to avoid blocking) + sendEvent({ + event: { + action: 'table_created', + properties: { + method: 'table_editor', + schema_name: payload.schema, + table_name: payload.name, + }, + groups: { + project: projectRef, + ...(organizationSlug && { organization: organizationSlug }), + }, + }, + }).catch((error) => { + console.error('Failed to track table creation event:', error) + }) + + // Track RLS enablement event if enabled (fire-and-forget) + if (isRLSEnabled) { + sendEvent({ event: { - action: 'table_created', + action: 'table_rls_enabled', properties: { method: 'table_editor', schema_name: payload.schema, @@ -503,199 +616,115 @@ export const createTable = async ({ ...(organizationSlug && { organization: organizationSlug }), }, }, + }).catch((error) => { + console.error('Failed to track RLS enablement event:', error) }) - } catch (error) { - console.error('Failed to track table creation event:', error) } - const table = await queryClient.fetchQuery({ - queryKey: tableKeys.retrieve(projectRef, payload.name, payload.schema), - queryFn: ({ signal }) => - getTable( - { projectRef, connectionString, name: payload.name, schema: payload.schema }, - signal - ), + // Fetch the created table + const table = await getTableQuery({ + projectRef, + connectionString, + name: payload.name, + schema: payload.schema, }) - // If we face any errors during this process after the actual table creation - // We'll delete the table as a way to clean up and not leave behind bits that - // got through successfully. This is so that the user can continue editing in - // the table side panel editor conveniently - try { - // Toggle RLS if configured to be - if (isRLSEnabled) { - await updateTableMutation({ + // If the user is importing data via a spreadsheet + if (importContent !== undefined) { + if (importContent.file && importContent.rowCount > 0) { + // Via a CSV file + const { error }: any = await insertRowsViaSpreadsheet( projectRef, connectionString, - id: table.id, - name: table.name, - schema: table.schema, - payload: { rls_enabled: isRLSEnabled }, - }) + importContent.file, + table, + importContent.selectedHeaders, + (progress: number) => { + toast.loading( +
+ +
, + { id: toastId } + ) + } + ) - // Track RLS enablement event - try { - await sendEvent({ - event: { - action: 'table_rls_enabled', - properties: { - method: 'table_editor', - schema_name: table.schema, - table_name: table.name, - }, - groups: { - project: projectRef, - ...(organizationSlug && { organization: organizationSlug }), - }, - }, - }) - } catch (error) { - console.error('Failed to track RLS enablement event:', error) + if (error !== undefined) { + toast.error('Do check your spreadsheet if there are any discrepancies.') + const message = `Table ${table.name} has been created but we ran into an error while inserting rows: ${error.message}` + toast.error(message) + console.error('Error:', { error, message }) } - } - - // Then insert the columns - we don't do Promise.all as we want to keep the integrity - // of the column order during creation. Note that we add primary key constraints separately - // via the query endpoint to support composite primary keys as pg-meta does not support that OOB - toast.loading(`Adding ${columns.length} columns to ${table.name}...`, { id: toastId }) - - for (const column of columns) { - // We create all columns without primary keys first - const columnPayload = generateCreateColumnPayload(table, { - ...column, - isPrimaryKey: false, - }) - await createDatabaseColumn({ + } else { + // Via text copy and paste + await insertTableRows( projectRef, connectionString, - payload: columnPayload, - }) - } - - // Then add the primary key constraints here to support composite keys - const primaryKeyColumns = columns - .filter((column) => column.isPrimaryKey) - .map((column) => column.name) - if (primaryKeyColumns.length > 0) { - await addPrimaryKey(projectRef, connectionString, table.schema, table.name, primaryKeyColumns) + table, + importContent.rows, + importContent.selectedHeaders, + (progress: number) => { + toast.loading( +
+ +
, + { id: toastId } + ) + } + ) } - // Then add the foreign key constraints here - if (foreignKeyRelations.length > 0) { - await addForeignKey({ + // For identity columns, manually raise the sequences (batched for performance) + const identityColumns = columns.filter((column) => column.isIdentity) + if (identityColumns.length > 0) { + const updateSequenceSQL = identityColumns + .map((column) => + getUpdateIdentitySequenceSQL({ + schema: table.schema, + table: table.name, + column: column.name, + }) + ) + .join(';\n') + await executeSql({ projectRef, connectionString, - table: { schema: table.schema, name: table.name }, - foreignKeys: foreignKeyRelations, + sql: updateSequenceSQL, + queryKey: ['sequences', 'update-batch'], }) } + } - // If the user is importing data via a spreadsheet - if (importContent !== undefined) { - if (importContent.file && importContent.rowCount > 0) { - // Via a CSV file - const { error }: any = await insertRowsViaSpreadsheet( - projectRef, - connectionString, - importContent.file, - table, - importContent.selectedHeaders, - (progress: number) => { - toast.loading( -
- -
, - { id: toastId } - ) - } - ) - - // For identity columns, manually raise the sequences - const identityColumns = columns.filter((column) => column.isIdentity) - for (const column of identityColumns) { - await executeSql({ - projectRef, - connectionString, - sql: `SELECT setval('${table.name}_${column.name}_seq', (SELECT MAX("${column.name}") FROM "${table.name}"));`, - }) - } - - if (error !== undefined) { - toast.error('Do check your spreadsheet if there are any discrepancies.') - - const message = `Table ${table.name} has been created but we ran into an error while inserting rows: ${error.message}` - toast.error(message) - console.error('Error:', { error, message }) - } - } else { - // Via text copy and paste - await insertTableRows( - projectRef, - connectionString, - table, - importContent.rows, - importContent.selectedHeaders, - (progress: number) => { - toast.loading( -
- -
, - { id: toastId } - ) - } - ) - - // For identity columns, manually raise the sequences - const identityColumns = columns.filter((column) => column.isIdentity) - for (const column of identityColumns) { - await executeSql({ - projectRef, - connectionString, - sql: `SELECT setval('${table.name}_${column.name}_seq', (SELECT MAX("${column.name}") FROM "${table.name}"));`, - }) - } - } - } - - await prefetchEditorTablePage({ - queryClient, - projectRef, - connectionString, - id: table.id, - }) + await prefetchEditorTablePage({ + queryClient, + projectRef, + connectionString, + id: table.id, + }) - // Finally, return the created table - return table - } catch (error) { - deleteTableMutation({ - projectRef, - connectionString, - id: table.id, - name: table.name, - schema: table.schema, - }) - throw error - } + // Finally, return the created table + return table } +/** TODO: Refactor to do in a single transaction */ export const updateTable = async ({ projectRef, connectionString, diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.tsx index fcee168621025..515e7df85caca 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.tsx @@ -5,13 +5,13 @@ import { toast } from 'sonner' import { useParams } from 'common' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useChanged } from 'hooks/misc/useChanged' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useTableEditorStateSnapshot } from 'state/table-editor' import { SidePanel, Tabs } from 'ui' -import ActionBar from '../ActionBar' +import { ActionBar } from '../ActionBar' import type { ImportContent } from '../TableEditor/TableEditor.types' import SpreadSheetFileUpload from './SpreadSheetFileUpload' -import { SpreadsheetImportPreview } from './SpreadsheetImportPreview' import SpreadsheetImportConfiguration from './SpreadSheetImportConfiguration' import SpreadSheetTextInput from './SpreadSheetTextInput' import { EMPTY_SPREADSHEET_DATA } from './SpreadsheetImport.constants' @@ -21,7 +21,7 @@ import { parseSpreadsheet, parseSpreadsheetText, } from './SpreadsheetImport.utils' -import { useChanged } from 'hooks/misc/useChanged' +import { SpreadsheetImportPreview } from './SpreadsheetImportPreview' interface SpreadsheetImportProps { debounceDuration?: number diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/HeaderTitle.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/HeaderTitle.tsx index 2c433e3c8b1e3..c3c56c2063e5e 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/HeaderTitle.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/HeaderTitle.tsx @@ -4,7 +4,7 @@ interface HeaderTitleProps { isDuplicating: boolean } -const HeaderTitle = ({ schema, table, isDuplicating }: HeaderTitleProps) => { +export const HeaderTitle = ({ schema, table, isDuplicating }: HeaderTitleProps) => { if (!table) { return ( <> @@ -25,5 +25,3 @@ const HeaderTitle = ({ schema, table, isDuplicating }: HeaderTitleProps) => { ) } - -export default HeaderTitle diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx index ab61d1a6be5a2..0533e95c1ba75 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx @@ -8,22 +8,22 @@ import { useDatabasePublicationsQuery } from 'data/database-publications/databas import { CONSTRAINT_TYPE, useTableConstraintsQuery } from 'data/database/constraints-query' import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query' import { useEnumeratedTypesQuery } from 'data/enumerated-types/enumerated-types-query' -import { useTrack } from 'lib/telemetry/track' import { useCustomContent } from 'hooks/custom-content/useCustomContent' import { useChanged } from 'hooks/misc/useChanged' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { RealtimeButtonVariant, useRealtimeExperiment } from 'hooks/misc/useRealtimeExperiment' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { useRealtimeExperiment, RealtimeButtonVariant } from 'hooks/misc/useRealtimeExperiment' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useUrlState } from 'hooks/ui/useUrlState' import { useProtectedSchemas } from 'hooks/useProtectedSchemas' import { DOCS_URL } from 'lib/constants' +import { useTrack } from 'lib/telemetry/track' import { type PlainObject } from 'lib/type-helpers' import { TableEditorStateContext, useTableEditorStateSnapshot } from 'state/table-editor' import { Badge, Checkbox, Input, SidePanel } from 'ui' import { Admonition } from 'ui-patterns' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import ActionBar from '../ActionBar' +import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' +import { ActionBar } from '../ActionBar' import type { ForeignKey } from '../ForeignKeySelector/ForeignKeySelector.types' import { formatForeignKeys } from '../ForeignKeySelector/ForeignKeySelector.utils' import type { SaveTableParams } from '../SidePanelEditor' @@ -31,7 +31,7 @@ import type { ColumnField } from '../SidePanelEditor.types' import { SpreadsheetImport } from '../SpreadsheetImport/SpreadsheetImport' import ColumnManagement from './ColumnManagement' import { ForeignKeysManagement } from './ForeignKeysManagement/ForeignKeysManagement' -import HeaderTitle from './HeaderTitle' +import { HeaderTitle } from './HeaderTitle' import RLSDisableModalContent from './RLSDisableModal' import { DEFAULT_COLUMNS } from './TableEditor.constants' import type { ImportContent, TableField } from './TableEditor.types' @@ -319,6 +319,7 @@ export const TableEditor = ({ const importedColumns = formatImportedContentToColumnFields(importContent) onUpdateField({ columns: importedColumns }) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [importContent]) if (!tableFields) return null diff --git a/apps/studio/data/tables/table-retrieve-query.ts b/apps/studio/data/tables/table-retrieve-query.ts index 219860136e142..ddda1c817b78a 100644 --- a/apps/studio/data/tables/table-retrieve-query.ts +++ b/apps/studio/data/tables/table-retrieve-query.ts @@ -1,6 +1,7 @@ import pgMeta from '@supabase/pg-meta' import { useQuery } from '@tanstack/react-query' +import { getQueryClient } from 'data/query-client' import { executeSql } from 'data/sql/execute-sql-query' import type { ResponseError, UseCustomQueryOptions } from 'types' import { tableKeys } from './keys' @@ -34,7 +35,7 @@ export type RetrieveTableResult = Awaited> export type RetrieveTableError = ResponseError export type RetrievedTableColumn = NonNullable[number] -export const useTablesQuery = ( +export const useTableQuery = ( { projectRef, connectionString, name, schema }: TablesVariables, { enabled = true, @@ -48,3 +49,26 @@ export const useTablesQuery = ( ...options, }) } + +/** + * Non-hook usage to fetch data + caching it into the store + */ +export const getTableQuery = async ({ + projectRef, + name, + schema, + connectionString, +}: { + projectRef: string + name: string + schema: string + connectionString?: string | null +}) => { + const queryClient = getQueryClient() + const table = await queryClient.fetchQuery({ + // eslint-disable-next-line @tanstack/query/exhaustive-deps + queryKey: tableKeys.retrieve(projectRef, name, schema), + queryFn: ({ signal }) => getTable({ projectRef, connectionString, name, schema }, signal), + }) + return table +} From d0769de174a619293a9155b9c92e6a3cf8d631d3 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Thu, 27 Nov 2025 10:00:24 -0400 Subject: [PATCH 03/14] fix headings levels in dedicated poolers blog post (#40868) fix headings levels --- apps/www/_blog/2025-03-07-dedicated-poolers.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/www/_blog/2025-03-07-dedicated-poolers.mdx b/apps/www/_blog/2025-03-07-dedicated-poolers.mdx index 7fcad0837c69d..3a8b1e0cd1a3d 100644 --- a/apps/www/_blog/2025-03-07-dedicated-poolers.mdx +++ b/apps/www/_blog/2025-03-07-dedicated-poolers.mdx @@ -23,13 +23,13 @@ Don't know what a Pooler is? Check out our [docs](/docs/guides/database/connecti This is available today for select customers, and will be generally available by 20th March, 2025. If you want to be notified when it's ready, [sign up here](https://forms.supabase.com/dedicated-pooler). -### Supabase Dedicated Pooler +## Supabase Dedicated Pooler The Dedicated Pooler is a [PgBouncer](https://www.pgbouncer.org/) instance that's co-located with your Postgres database. This will require you to connect with IPv6 or, if that's not an option, you can use the [IPv4 add-on](/docs/guides/platform/ipv4-address). The dedicated pooler is isolated to your own project and grants you fine-grained control over the configuration. -### Connecting to your database +## Connecting to your database This gives you now 3 options for connecting to your database: @@ -72,6 +72,6 @@ In the recent months, our platform has seen unprecedented growth. Tens of thousa Introducing Dedicated Poolers gives you the flexibility to choose the right connection type for your use case. If you need dedicated hardware, you can now opt for a Dedicated Pooler on the Pro Plan and above for lower latency, better performance, and higher reliability. -### Getting started +## Getting started Dedicated Poolers are available today for our Enterprise customers, and will be generally available by 20th March, 2025. If you want to be notified when it's ready, [sign up here](https://forms.supabase.com/dedicated-pooler). From ba50252b5a93506ef99564d75b5e4c32d25174b3 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Thu, 27 Nov 2025 16:23:42 +0200 Subject: [PATCH 04/14] feat: update @supabase/*-js libraries to v2.86.0 (#40867) --- pnpm-lock.yaml | 111 +++++++++++++++++++++++--------------------- pnpm-workspace.yaml | 9 ++-- 2 files changed, 64 insertions(+), 56 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8767f70c95a2..d2ccb553069bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,17 +7,17 @@ settings: catalogs: default: '@supabase/auth-js': - specifier: 2.83.0 - version: 2.83.0 + specifier: 2.86.0 + version: 2.86.0 '@supabase/postgrest-js': - specifier: 2.83.0 - version: 2.83.0 + specifier: 2.86.0 + version: 2.86.0 '@supabase/realtime-js': - specifier: 2.83.0 - version: 2.83.0 + specifier: 2.86.0 + version: 2.86.0 '@supabase/supabase-js': - specifier: 2.83.0 - version: 2.83.0 + specifier: 2.86.0 + version: 2.86.0 '@types/node': specifier: ^22.0.0 version: 22.13.14 @@ -64,7 +64,7 @@ catalogs: overrides: '@react-router/dev>vite-node': 3.2.4 '@redocly/respect-core>form-data': ^4.0.4 - '@supabase/supabase-js>@supabase/auth-js': 2.83.0 + '@supabase/supabase-js>@supabase/auth-js': 2.86.0 '@tanstack/directive-functions-plugin>vite': ^7.1.11 '@tanstack/react-start-plugin>vite': ^7.1.11 vinxi>vite: ^7.1.11 @@ -415,10 +415,10 @@ importers: version: 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: ^10.3.0 - version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) + version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) '@supabase/supabase-js': specifier: 'catalog:' - version: 2.83.0 + version: 2.86.0 '@tailwindcss/container-queries': specifier: ^0.1.1 version: 0.1.1(tailwindcss@3.4.1(ts-node@10.9.2(@types/node@22.13.14)(typescript@5.9.2))) @@ -833,7 +833,7 @@ importers: version: 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: ^10.3.0 - version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) + version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) '@std/path': specifier: npm:@jsr/std__path@^1.0.8 version: '@jsr/std__path@1.0.8' @@ -845,7 +845,7 @@ importers: version: 7.5.0 '@supabase/auth-js': specifier: 'catalog:' - version: 2.83.0 + version: 2.86.0 '@supabase/mcp-server-supabase': specifier: ^0.5.8 version: 0.5.8(supports-color@8.1.1) @@ -857,7 +857,7 @@ importers: version: link:../../packages/pg-meta '@supabase/realtime-js': specifier: 'catalog:' - version: 2.83.0 + version: 2.86.0 '@supabase/shared-types': specifier: 0.1.83 version: 0.1.83 @@ -866,7 +866,7 @@ importers: version: 0.1.6(encoding@0.1.13)(supports-color@8.1.1) '@supabase/supabase-js': specifier: 'catalog:' - version: 2.83.0 + version: 2.86.0 '@tanstack/react-query': specifier: ^4.42.0 version: 4.42.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1359,7 +1359,7 @@ importers: version: 7.4.0(@react-router/dev@7.4.0(@types/node@22.13.14)(babel-plugin-macros@3.1.0)(jiti@2.5.1)(react-router@7.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@5.9.2)(vite@7.1.11(@types/node@22.13.14)(jiti@2.5.1)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1))(typescript@5.9.2) '@supabase/postgrest-js': specifier: 'catalog:' - version: 2.83.0 + version: 2.86.0 '@supabase/supa-mdx-lint': specifier: 0.2.6-alpha version: 0.2.6-alpha @@ -1483,10 +1483,10 @@ importers: version: 1.6.0 '@supabase/ssr': specifier: ^0.7.0 - version: 0.7.0(@supabase/supabase-js@2.83.0) + version: 0.7.0(@supabase/supabase-js@2.86.0) '@supabase/supabase-js': specifier: 'catalog:' - version: 2.83.0 + version: 2.86.0 '@tanstack/react-router': specifier: ^1.114.27 version: 1.114.27(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1594,10 +1594,10 @@ importers: version: 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: ^10 - version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) + version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) '@supabase/supabase-js': specifier: 'catalog:' - version: 2.83.0 + version: 2.86.0 '@vercel/og': specifier: ^0.6.2 version: 0.6.2 @@ -1826,10 +1826,10 @@ importers: dependencies: '@supabase/ssr': specifier: ^0.7.0 - version: 0.7.0(@supabase/supabase-js@2.83.0) + version: 0.7.0(@supabase/supabase-js@2.86.0) '@supabase/supabase-js': specifier: 'catalog:' - version: 2.83.0 + version: 2.86.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1878,7 +1878,7 @@ importers: version: 0.18.5 '@supabase/supabase-js': specifier: 'catalog:' - version: 2.83.0 + version: 2.86.0 ai: specifier: ^5.0.0 version: 5.0.2(zod@3.25.76) @@ -1972,10 +1972,10 @@ importers: dependencies: '@supabase/auth-js': specifier: 'catalog:' - version: 2.83.0 + version: 2.86.0 '@supabase/supabase-js': specifier: 'catalog:' - version: 2.83.0 + version: 2.86.0 '@types/dat.gui': specifier: ^0.7.12 version: 0.7.12 @@ -2443,7 +2443,7 @@ importers: version: 0.1.6(encoding@0.1.13)(supports-color@8.1.1) '@supabase/supabase-js': specifier: 'catalog:' - version: 2.83.0 + version: 2.86.0 '@vitest/coverage-v8': specifier: ^3.2.0 version: 3.2.4(supports-color@8.1.1)(vitest@3.2.4) @@ -8841,12 +8841,12 @@ packages: resolution: {integrity: sha512-Cq3KKe+G1o7PSBMbmrgpT2JgBeyH2THHr3RdIX2MqF7AnBuspIMgtZ3ktcCgP7kZsTMvnmWymr7zZCT1zeWbMw==} engines: {node: '>=12.16'} - '@supabase/auth-js@2.83.0': - resolution: {integrity: sha512-xmyFcglbAo6C2ox5T9FjZryqk50xU23QqoNKnEYn7mjgxghP/A13W64lL3/TF8HtbuCt3Esk9d3Jw5afXTO/ew==} + '@supabase/auth-js@2.86.0': + resolution: {integrity: sha512-3xPqMvBWC6Haqpr6hEWmSUqDq+6SA1BAEdbiaHdAZM9QjZ5uiQJ+6iD9pZOzOa6MVXZh4GmwjhC9ObIG0K1NcA==} engines: {node: '>=20.0.0'} - '@supabase/functions-js@2.83.0': - resolution: {integrity: sha512-fRfPbyWB6MsovTINpSC21HhU1hfY/4mcXLsDV34sC2b/5i0mZYTBaCbuy4yfTG1vcxCzKDqMgAIC//lewnafrg==} + '@supabase/functions-js@2.86.0': + resolution: {integrity: sha512-AlOoVfeaq9XGlBFIyXTmb+y+CZzxNO4wWbfgRM6iPpNU5WCXKawtQYSnhivi3UVxS7GA0rWovY4d6cIAxZAojA==} engines: {node: '>=20.0.0'} '@supabase/mcp-server-supabase@0.5.8': @@ -8863,12 +8863,12 @@ packages: resolution: {integrity: sha512-vz5gc6RKNfDVnIfRUmH2ssTMYFI0U3MYOVyQ9R4YkzOS2dKSanjC4rTEDGjlMFwGTCUPW3N3pbY7HJIW81wMyg==} engines: {node: '>=16', npm: '>=8'} - '@supabase/postgrest-js@2.83.0': - resolution: {integrity: sha512-qjVwbP9JXwgd/YbOj/soWvOUl5c/jyI/L7zs7VDxl5HEq64Gs4ZI5OoDcml+HcOwxFFxVytYeyQLd0rSWWNRIQ==} + '@supabase/postgrest-js@2.86.0': + resolution: {integrity: sha512-QVf+wIXILcZJ7IhWhWn+ozdf8B+oO0Ulizh2AAPxD/6nQL+x3r9lJ47a+fpc/jvAOGXMbkeW534Kw6jz7e8iIA==} engines: {node: '>=20.0.0'} - '@supabase/realtime-js@2.83.0': - resolution: {integrity: sha512-mT+QeXAD2gLoqNeQFLjTloDM62VR+VFV8OVdF8RscYpXZriBhabTLE2Auff5lkEJetFFclP1B8j+YtgrWqSmeA==} + '@supabase/realtime-js@2.86.0': + resolution: {integrity: sha512-dyS8bFoP29R/sj5zLi0AP3JfgG8ar1nuImcz5jxSx7UIW7fbFsXhUCVrSY2Ofo0+Ev6wiATiSdBOzBfWaiFyPA==} engines: {node: '>=20.0.0'} '@supabase/shared-types@0.1.83': @@ -8882,8 +8882,8 @@ packages: peerDependencies: '@supabase/supabase-js': ^2.43.4 - '@supabase/storage-js@2.83.0': - resolution: {integrity: sha512-qmOM8E6HH/+dm6tW0Tu9Q/TuM035pI3AuKegvQERZRLLk3HtPms5O8UaYh6zi5LZaPtM9u5fldv1W6AUKkKLDQ==} + '@supabase/storage-js@2.86.0': + resolution: {integrity: sha512-PM47jX/Mfobdtx7NNpoj9EvlrkapAVTQBZgGGslEXD6NS70EcGjhgRPBItwHdxZPM5GwqQ0cGMN06uhjeY2mHQ==} engines: {node: '>=20.0.0'} '@supabase/supa-mdx-lint-darwin@0.2.6-alpha': @@ -8976,8 +8976,8 @@ packages: resolution: {integrity: sha512-TNbBLSofM6jQg3JwzO4lttd59dScTTzW4p504/OWcgRWghQLRNfxXRJJtdui83gBMLWpgeUZqvgtfYIwS1Flzw==} hasBin: true - '@supabase/supabase-js@2.83.0': - resolution: {integrity: sha512-X0OOgJQfD9BDNhxfslozuq/26fPyBt+TsMX+YkI2T6Hc4M2bkCDho/D4LC8nV9gNtviuejWdhit8YzHwnKOQoQ==} + '@supabase/supabase-js@2.86.0': + resolution: {integrity: sha512-BaC9sv5+HGNy1ulZwY8/Ev7EjfYYmWD4fOMw9bDBqTawEj6JHAiOHeTwXLRzVaeSay4p17xYLN2NSCoGgXMQnw==} engines: {node: '>=20.0.0'} '@swc/helpers@0.5.15': @@ -13503,6 +13503,10 @@ packages: hyphenate-style-name@1.0.4: resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==} + iceberg-js@0.8.0: + resolution: {integrity: sha512-kmgmea2nguZEvRqW79gDqNXyxA3OS5WIgMVffrHpqXV4F/J4UmNIw2vstixioLTNSkd5rFB8G0s3Lwzogm6OFw==} + engines: {node: '>=20.0.0'} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -27624,7 +27628,7 @@ snapshots: '@sentry/core@10.3.0': {} - '@sentry/nextjs@10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)': + '@sentry/nextjs@10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.36.0 @@ -28415,11 +28419,11 @@ snapshots: '@stripe/stripe-js@7.5.0': {} - '@supabase/auth-js@2.83.0': + '@supabase/auth-js@2.86.0': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.83.0': + '@supabase/functions-js@2.86.0': dependencies: tslib: 2.8.1 @@ -28466,11 +28470,11 @@ snapshots: - pg-native - supports-color - '@supabase/postgrest-js@2.83.0': + '@supabase/postgrest-js@2.86.0': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.83.0': + '@supabase/realtime-js@2.86.0': dependencies: '@types/phoenix': 1.6.6 '@types/ws': 8.18.1 @@ -28491,13 +28495,14 @@ snapshots: - encoding - supports-color - '@supabase/ssr@0.7.0(@supabase/supabase-js@2.83.0)': + '@supabase/ssr@0.7.0(@supabase/supabase-js@2.86.0)': dependencies: - '@supabase/supabase-js': 2.83.0 + '@supabase/supabase-js': 2.86.0 cookie: 1.0.2 - '@supabase/storage-js@2.83.0': + '@supabase/storage-js@2.86.0': dependencies: + iceberg-js: 0.8.0 tslib: 2.8.1 '@supabase/supa-mdx-lint-darwin@0.2.6-alpha': @@ -28564,13 +28569,13 @@ snapshots: '@supabase/supa-mdx-lint-win32-x64': 0.3.1 node-pty: 1.0.0 - '@supabase/supabase-js@2.83.0': + '@supabase/supabase-js@2.86.0': dependencies: - '@supabase/auth-js': 2.83.0 - '@supabase/functions-js': 2.83.0 - '@supabase/postgrest-js': 2.83.0 - '@supabase/realtime-js': 2.83.0 - '@supabase/storage-js': 2.83.0 + '@supabase/auth-js': 2.86.0 + '@supabase/functions-js': 2.86.0 + '@supabase/postgrest-js': 2.86.0 + '@supabase/realtime-js': 2.86.0 + '@supabase/storage-js': 2.86.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -34247,6 +34252,8 @@ snapshots: hyphenate-style-name@1.0.4: {} + iceberg-js@0.8.0: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4e058a743ce2b..92e386b07879f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,10 +5,10 @@ packages: - e2e/* catalog: - '@supabase/auth-js': 2.83.0 - '@supabase/realtime-js': 2.83.0 - '@supabase/supabase-js': 2.83.0 - '@supabase/postgrest-js': 2.83.0 + '@supabase/auth-js': 2.86.0 + '@supabase/realtime-js': 2.86.0 + '@supabase/supabase-js': 2.86.0 + '@supabase/postgrest-js': 2.86.0 '@types/node': ^22.0.0 '@types/react': ^18.3.0 '@types/react-dom': ^18.3.0 @@ -44,6 +44,7 @@ minimumReleaseAgeExclude: - '@supabase/*' - ai - supabase + - iceberg-js onlyBuiltDependencies: - supabase From ce1cf19b511343580fdf51c60f3f5b94eea794e4 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Thu, 27 Nov 2025 16:32:31 +0200 Subject: [PATCH 05/14] ci: add contents-write to fix-typos (#40869) --- .github/workflows/fix-typos.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/fix-typos.yml b/.github/workflows/fix-typos.yml index a7af81d334d56..02c50f7416ddf 100644 --- a/.github/workflows/fix-typos.yml +++ b/.github/workflows/fix-typos.yml @@ -10,7 +10,7 @@ concurrency: cancel-in-progress: true permissions: - contents: read + contents: write pull-requests: write jobs: From 4bfe7d048006ee8f14bd0bfe5fb6a5ab7661d5aa Mon Sep 17 00:00:00 2001 From: Marouane Souda <61951643+marsou001@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:17:07 +0100 Subject: [PATCH 06/14] fix(editor): close previous and next page modals on confirm (#40843) Co-authored-by: Ali Waseem --- .../components/grid/components/footer/pagination/Pagination.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx index e6ea4edb63b7f..697f7ebeae7bb 100644 --- a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx +++ b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx @@ -129,6 +129,7 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = const onConfirmPreviousPage = () => { goToPreviousPage() + setIsConfirmPreviousModalOpen(false) } const onNextPage = () => { @@ -143,6 +144,7 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = const onConfirmNextPage = () => { goToNextPage() + setIsConfirmNextModalOpen(false) } const goToPreviousPage = () => { From 511b6faadac1ccc55d8f0c3da760b22541c7af79 Mon Sep 17 00:00:00 2001 From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com> Date: Thu, 27 Nov 2025 15:20:07 +0000 Subject: [PATCH 07/14] feat(studio): surface index advisor indicators (#40788) * feat: change the check to show index advisor tab at all times * fix: hide add to log drains on export menu in query perf * fix: small fallback for pathname check * fix: query perf header block responsiveness * feat: admonition for index advisor * fix: add aria-describedby to query perf sheet * feat: proper way to do sheet description * chore: better title spacing in panel * fix: indexes in use empty state * fix: key in observability menu * feat: better highlighting of index advisor issues * feat: add docs button to empty indexes tab * feat: remove unused code * feat: use button tooltips for reset and refresh to gain space * feat: add dismiss to index advisor banner * feat: add warnings filter to query perf * feat: filter all queries for warnings * fix: selected state for warning rows * fix: fallback for isLogs check * fix: other instance of download button --------- Co-authored-by: Ali Waseem --- .../IndexAdvisor/EnableIndexAdvisorButton.tsx | 23 +--- .../IndexAdvisor/IndexAdvisorNotice.tsx | 60 +++++++++ .../QueryPerformance/QueryIndexes.tsx | 34 +++++- .../QueryPerformanceChart.tsx | 4 +- .../QueryPerformanceFilterBar.tsx | 41 +++++-- .../QueryPerformance/QueryPerformanceGrid.tsx | 44 +++---- .../WithMonitor/WithMonitor.tsx | 2 + .../WithStatements/WithStatements.tsx | 21 ++-- .../interfaces/Reports/Reports.constants.ts | 115 ++++++++++-------- .../interfaces/Reports/Reports.queries.ts | 5 +- .../interfaces/Reports/Reports.types.ts | 3 +- .../components/DownloadLogsButton.tsx | 28 +++-- .../ObservabilityLayout/ObservabilityMenu.tsx | 8 +- .../components/ui/DownloadResultsButton.tsx | 18 ++- .../[ref]/observability/query-performance.tsx | 50 ++++---- packages/common/constants/local-storage.ts | 3 + 16 files changed, 290 insertions(+), 169 deletions(-) create mode 100644 apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/IndexAdvisorNotice.tsx diff --git a/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/EnableIndexAdvisorButton.tsx b/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/EnableIndexAdvisorButton.tsx index 741f8a3a153ab..06b938331b6ad 100644 --- a/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/EnableIndexAdvisorButton.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/EnableIndexAdvisorButton.tsx @@ -1,7 +1,6 @@ import { useState } from 'react' import { toast } from 'sonner' -import { useIndexAdvisorStatus } from 'components/interfaces/QueryPerformance/hooks/useIsIndexAdvisorStatus' import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' @@ -17,18 +16,12 @@ import { AlertDialogTrigger, Badge, Button, - InfoIcon, - Tooltip, - TooltipContent, - TooltipTrigger, } from 'ui' import { getIndexAdvisorExtensions } from './index-advisor.utils' export const EnableIndexAdvisorButton = () => { const { data: project } = useSelectedProjectQuery() - const { isIndexAdvisorAvailable, isIndexAdvisorEnabled } = useIndexAdvisorStatus() - const [isDialogOpen, setIsDialogOpen] = useState(false) const { data: extensions } = useDatabaseExtensionsQuery({ @@ -72,21 +65,11 @@ export const EnableIndexAdvisorButton = () => { } } - // if index_advisor is already enabled or not available to install, show nothing - if (!isIndexAdvisorAvailable || isIndexAdvisorEnabled) return null - return ( setIsDialogOpen(!isDialogOpen)}> - - - - - - - Recommends indexes to improve query performance - + + + diff --git a/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/IndexAdvisorNotice.tsx b/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/IndexAdvisorNotice.tsx new file mode 100644 index 0000000000000..c1fe27915290f --- /dev/null +++ b/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/IndexAdvisorNotice.tsx @@ -0,0 +1,60 @@ +import { useIndexAdvisorStatus } from 'components/interfaces/QueryPerformance/hooks/useIsIndexAdvisorStatus' +import { BASE_PATH } from 'lib/constants' +import { Admonition } from 'ui-patterns' +import { EnableIndexAdvisorButton } from './EnableIndexAdvisorButton' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { LOCAL_STORAGE_KEYS } from 'common' +import { useParams } from 'common/hooks' +import { Button } from 'ui' + +export const IndexAdvisorNotice = () => { + const { ref } = useParams() + const { isIndexAdvisorAvailable, isIndexAdvisorEnabled } = useIndexAdvisorStatus() + const [isDismissed, setIsDismissed] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.INDEX_ADVISOR_NOTICE_DISMISSED(ref ?? ''), + false + ) + + if (!isIndexAdvisorAvailable || isIndexAdvisorEnabled || isDismissed) return null + + return ( +
+ +
+ Index Advisor + Index Advisor +
+
+
+
+
+

Enable Index Advisor

+
+

+ Recommends indexes to improve query performance. +

+
+
+ + +
+
+ +
+ ) +} diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx index 036b2bfb867bf..5e4e85447f3be 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx @@ -32,6 +32,9 @@ import { createIndexes, hasIndexRecommendations, } from './IndexAdvisor/index-advisor.utils' +import { EnableIndexAdvisorButton } from './IndexAdvisor/EnableIndexAdvisorButton' +import { DocsButton } from 'components/ui/DocsButton' +import { DOCS_URL } from 'lib/constants' interface QueryIndexesProps { selectedRow: any @@ -122,11 +125,30 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => { } } + if (!isLoadingExtensions && !isIndexAdvisorEnabled) { + return ( + + +
+

Enable Index Advisor

+

+ Recommends indexes to improve query performance. +

+
+ + +
+
+
+
+ ) + } + return (
-

Indexes in use

+

Indexes in use

This query is using the following index{(usedIndexes ?? []).length > 1 ? 's' : ''}:

@@ -142,7 +164,7 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => { {isSuccess && (
{usedIndexes.length === 0 && ( -
+

No indexes are involved in this query

@@ -173,7 +195,7 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
-

New index recommendations

+

New index recommendations

{isLoadingExtensions ? ( ) : !isIndexAdvisorEnabled ? ( @@ -252,8 +274,8 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => { <>
-

Query costs

-
+

Query costs

+
{
-

FAQ

+

FAQ

diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceChart.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceChart.tsx index a8672d4ac2a85..a8dc5314b8353 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceChart.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceChart.tsx @@ -335,7 +335,7 @@ export const QueryPerformanceChart = ({ -
+
{isLoading ? ( ) : error ? ( @@ -375,7 +375,7 @@ export const QueryPerformanceChart = ({ tickFormatter: getYAxisFormatter, }} xAxisIsDate={true} - className="mt-6" + className="mt-2" />
)} diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceFilterBar.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceFilterBar.tsx index 33af5d28f9a3b..69377159ae96b 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceFilterBar.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceFilterBar.tsx @@ -13,6 +13,7 @@ import { ReportsNumericFilter, NumericFilter, } from 'components/interfaces/Reports/v2/ReportsNumericFilter' +import { useIndexAdvisorStatus } from './hooks/useIsIndexAdvisorStatus' export const QueryPerformanceFilterBar = ({ actions, @@ -23,16 +24,20 @@ export const QueryPerformanceFilterBar = ({ }) => { const { data: project } = useSelectedProjectQuery() const { sort, clearSort } = useQueryPerformanceSort() + const { isIndexAdvisorEnabled } = useIndexAdvisorStatus() - const [{ search: searchQuery, roles: defaultFilterRoles, callsFilter }, setSearchParams] = - useQueryStates({ - search: parseAsString.withDefault(''), - roles: parseAsArrayOf(parseAsString).withDefault([]), - callsFilter: parseAsJson((value) => value as NumericFilter | null).withDefault({ - operator: '>=', - value: 0, - } as NumericFilter), - }) + const [ + { search: searchQuery, roles: defaultFilterRoles, callsFilter, indexAdvisor }, + setSearchParams, + ] = useQueryStates({ + search: parseAsString.withDefault(''), + roles: parseAsArrayOf(parseAsString).withDefault([]), + callsFilter: parseAsJson((value) => value as NumericFilter | null).withDefault({ + operator: '>=', + value: 0, + } as NumericFilter), + indexAdvisor: parseAsString.withDefault('false'), + }) const { data, isLoading: isLoadingRoles } = useDatabaseRolesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, @@ -56,11 +61,17 @@ export const QueryPerformanceFilterBar = ({ setSearchParams({ roles }) } + const onIndexAdvisorChange = (options: string[]) => { + setSearchParams({ indexAdvisor: options.includes('true') ? 'true' : 'false' }) + } + useEffect(() => { onSearchQueryChange(searchValue) // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchValue]) + const indexAdvisorOptions = [{ value: 'true', label: 'Index Advisor' }] + return (
@@ -111,6 +122,18 @@ export const QueryPerformanceFilterBar = ({ /> )} + {isIndexAdvisorEnabled && ( + + )} + {sort && (

diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx index 9a3157d1f2a06..ee5b1acd2bd91 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx @@ -13,6 +13,7 @@ import { DropdownMenuTrigger, Sheet, SheetContent, + SheetDescription, SheetTitle, TabsContent_Shadcn_, TabsList_Shadcn_, @@ -40,8 +41,8 @@ import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFi interface QueryPerformanceGridProps { aggregatedData: QueryPerformanceRow[] isLoading: boolean - currentSelectedQuery?: string | null // Make optional - onCurrentSelectQuery?: (query: string) => void // Make optional + currentSelectedQuery?: string | null + onCurrentSelectQuery?: (query: string) => void } const calculateTimeConsumedWidth = (data: QueryPerformanceRow[]) => { @@ -397,14 +398,6 @@ export const QueryPerformanceGrid = ({ return data }, [aggregatedData, sort, search, roles, callsFilter]) - const selectedQuery = selectedRow !== undefined ? reportData[selectedRow]?.query : undefined - const query = (selectedQuery ?? '').trim().toLowerCase() - const showIndexSuggestions = - (query.startsWith('select') || - query.startsWith('with pgrst_source') || - query.startsWith('with pgrst_payload')) && - hasIndexRecommendations(reportData[selectedRow!]?.index_advisor_result, true) - useEffect(() => { setSelectedRow(undefined) }, [search, roles, urlSort, order, callsFilter]) @@ -460,10 +453,14 @@ export const QueryPerformanceGrid = ({ const isSelected = idx === selectedRow const query = reportData[idx]?.query const isCharted = currentSelectedQuery ? currentSelectedQuery === query : false + const hasRecommendations = hasIndexRecommendations( + reportData[idx]?.index_advisor_result, + true + ) return [ - `${isSelected ? 'bg-surface-300 dark:bg-surface-300' : 'bg-200'} cursor-pointer`, - `${isSelected ? '[&>div:first-child]:border-l-4 border-l-secondary [&>div]:!border-l-foreground' : ''}`, + `${isSelected ? (hasRecommendations ? 'bg-warning/10 hover:bg-warning/20' : 'bg-surface-300 dark:bg-surface-300') : hasRecommendations ? 'bg-warning/10 hover:bg-warning/20' : 'bg-200 hover:bg-surface-200'} cursor-pointer`, + `${isSelected ? (hasRecommendations ? '[&>div:first-child]:border-l-4 border-l-warning [&>div]:border-l-warning' : '[&>div:first-child]:border-l-4 border-l-secondary [&>div]:!border-l-foreground') : ''}`, `${isCharted ? 'bg-surface-200 dark:bg-surface-200' : ''}`, `${isCharted ? '[&>div:first-child]:border-l-4 border-l-secondary [&>div]:border-l-brand' : ''}`, '[&>.rdg-cell]:box-border [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none', @@ -489,7 +486,11 @@ export const QueryPerformanceGrid = ({ } else { // Otherwise, open the detail panel setSelectedRow(idx) - setView('details') + const hasRecommendations = hasIndexRecommendations( + reportData[idx]?.index_advisor_result, + true + ) + setView(hasRecommendations ? 'suggestion' : 'details') gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx }) } } @@ -526,6 +527,9 @@ export const QueryPerformanceGrid = ({ modal={false} > Query details + + Query Performance Details & Indexes + Query details - {showIndexSuggestions && ( - - Indexes - - )} + + Indexes +

diff --git a/apps/studio/components/interfaces/QueryPerformance/WithMonitor/WithMonitor.tsx b/apps/studio/components/interfaces/QueryPerformance/WithMonitor/WithMonitor.tsx index db34a8def4c6c..1199dc87f64ae 100644 --- a/apps/studio/components/interfaces/QueryPerformance/WithMonitor/WithMonitor.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/WithMonitor/WithMonitor.tsx @@ -14,6 +14,7 @@ import { } from './WithMonitor.utils' import { useParams } from 'common' import { DownloadResultsButton } from 'components/ui/DownloadResultsButton' +import { IndexAdvisorNotice } from '../IndexAdvisor/IndexAdvisorNotice' dayjs.extend(utc) @@ -81,6 +82,7 @@ export const WithMonitor = ({ dateRange, onDateRangeChange }: WithMonitorProps) return ( <> + + - - + tooltip={{ content: { side: 'top', text: 'Reset report' } }} + className="w-[26px]" + /> + ` + sql: (_params, where, orderBy, runIndexAdvisor = false, filterIndexAdvisor = false) => ` -- reports-query-performance-most-frequently-invoked set search_path to public, extensions; @@ -416,7 +416,7 @@ select }, mostTimeConsuming: { queryType: 'db', - sql: (_, where, orderBy, runIndexAdvisor = false) => ` + sql: (_, where, orderBy, runIndexAdvisor = false, filterIndexAdvisor = false) => ` -- reports-query-performance-most-time-consuming set search_path to public, extensions; @@ -454,7 +454,7 @@ select }, slowestExecutionTime: { queryType: 'db', - sql: (_params, where, orderBy, runIndexAdvisor = false) => ` + sql: (_params, where, orderBy, runIndexAdvisor = false, filterIndexAdvisor = false) => ` -- reports-query-performance-slowest-execution-time set search_path to public, extensions; @@ -513,59 +513,68 @@ select }, unified: { queryType: 'db', - sql: (_params, where, orderBy, runIndexAdvisor = false) => ` + sql: (_params, where, orderBy, runIndexAdvisor = false, filterIndexAdvisor = false) => { + const baseQuery = ` -- reports-query-performance-unified set search_path to public, extensions; - select - auth.rolname, - statements.query, - statements.calls, - -- -- Postgres 13, 14, 15 - statements.total_exec_time + statements.total_plan_time as total_time, - statements.min_exec_time + statements.min_plan_time as min_time, - statements.max_exec_time + statements.max_plan_time as max_time, - statements.mean_exec_time + statements.mean_plan_time as mean_time, - -- -- Postgres <= 12 - -- total_time, - -- min_time, - -- max_time, - -- mean_time, - statements.rows / statements.calls as avg_rows, - statements.rows as rows_read, - statements.shared_blks_hit as debug_hit, - statements.shared_blks_read as debug_read, - case - when (statements.shared_blks_hit + statements.shared_blks_read) > 0 - then (statements.shared_blks_hit::numeric * 100.0) / - (statements.shared_blks_hit + statements.shared_blks_read) - else 0 - end as cache_hit_rate, - ((statements.total_exec_time + statements.total_plan_time)/sum(statements.total_exec_time + statements.total_plan_time) OVER()) * 100 as prop_total_time${ - runIndexAdvisor - ? `, - case - when (lower(statements.query) like 'select%' or lower(statements.query) like 'with pgrst%') - then ( - select json_build_object( - 'has_suggestion', array_length(index_statements, 1) > 0, - 'startup_cost_before', startup_cost_before, - 'startup_cost_after', startup_cost_after, - 'total_cost_before', total_cost_before, - 'total_cost_after', total_cost_after, - 'index_statements', index_statements + with query_results as ( + select + auth.rolname, + statements.query, + statements.calls, + -- -- Postgres 13, 14, 15 + statements.total_exec_time + statements.total_plan_time as total_time, + statements.min_exec_time + statements.min_plan_time as min_time, + statements.max_exec_time + statements.max_plan_time as max_time, + statements.mean_exec_time + statements.mean_plan_time as mean_time, + -- -- Postgres <= 12 + -- total_time, + -- min_time, + -- max_time, + -- mean_time, + statements.rows / statements.calls as avg_rows, + statements.rows as rows_read, + statements.shared_blks_hit as debug_hit, + statements.shared_blks_read as debug_read, + case + when (statements.shared_blks_hit + statements.shared_blks_read) > 0 + then (statements.shared_blks_hit::numeric * 100.0) / + (statements.shared_blks_hit + statements.shared_blks_read) + else 0 + end as cache_hit_rate, + ((statements.total_exec_time + statements.total_plan_time)/sum(statements.total_exec_time + statements.total_plan_time) OVER()) * 100 as prop_total_time${ + runIndexAdvisor + ? `, + case + when (lower(statements.query) like 'select%' or lower(statements.query) like 'with pgrst%') + then ( + select json_build_object( + 'has_suggestion', array_length(index_statements, 1) > 0, + 'startup_cost_before', startup_cost_before, + 'startup_cost_after', startup_cost_after, + 'total_cost_before', total_cost_before, + 'total_cost_after', total_cost_after, + 'index_statements', index_statements + ) + from index_advisor(statements.query) ) - from index_advisor(statements.query) - ) - else null - end as index_advisor_result` - : '' - } - from pg_stat_statements as statements - inner join pg_authid as auth on statements.userid = auth.oid - ${where || ''} - ${orderBy || 'order by statements.total_exec_time + statements.total_plan_time desc'} - limit 20`, + else null + end as index_advisor_result` + : '' + } + from pg_stat_statements as statements + inner join pg_authid as auth on statements.userid = auth.oid + ${where || ''} + ) + select * + from query_results + ${filterIndexAdvisor && runIndexAdvisor ? `where (index_advisor_result->>'has_suggestion')::boolean = true` : ''} + ${orderBy || 'order by total_time desc'} + limit 20` + + return baseQuery + }, }, slowQueriesCount: { queryType: 'db', @@ -580,7 +589,7 @@ select }, queryMetrics: { queryType: 'db', - sql: (_params, where, orderBy, runIndexAdvisor = false) => ` + sql: (_params, where, orderBy, runIndexAdvisor = false, filterIndexAdvisor = false) => ` -- reports-query-performance-metrics set search_path to public, extensions; diff --git a/apps/studio/components/interfaces/Reports/Reports.queries.ts b/apps/studio/components/interfaces/Reports/Reports.queries.ts index 3e56da4f3b056..cdfbcaf8cf12d 100644 --- a/apps/studio/components/interfaces/Reports/Reports.queries.ts +++ b/apps/studio/components/interfaces/Reports/Reports.queries.ts @@ -30,6 +30,7 @@ export type QueryPerformanceQueryOpts = { roles?: string[] runIndexAdvisor?: boolean minCalls?: number + filterIndexAdvisor?: boolean } export const useQueryPerformanceQuery = ({ @@ -39,6 +40,7 @@ export const useQueryPerformanceQuery = ({ roles, runIndexAdvisor = false, minCalls, + filterIndexAdvisor = false, }: QueryPerformanceQueryOpts) => { const queryPerfQueries = PRESET_CONFIG[Presets.QUERY_PERFORMANCE] const baseSQL = queryPerfQueries.queries[preset] @@ -58,7 +60,8 @@ export const useQueryPerformanceQuery = ({ [], whereSql.length > 0 ? `WHERE ${whereSql}` : undefined, orderBySql, - runIndexAdvisor + runIndexAdvisor, + filterIndexAdvisor ) return useDbQuery({ sql, diff --git a/apps/studio/components/interfaces/Reports/Reports.types.ts b/apps/studio/components/interfaces/Reports/Reports.types.ts index bc304f2d21a3d..246afb7fca702 100644 --- a/apps/studio/components/interfaces/Reports/Reports.types.ts +++ b/apps/studio/components/interfaces/Reports/Reports.types.ts @@ -25,7 +25,8 @@ export interface ReportQuery { filters: ReportFilterItem[], where?: string, orderBy?: string, - runIndexAdvisor?: boolean + runIndexAdvisor?: boolean, + filterIndexAdvisor?: boolean ) => string } diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/DownloadLogsButton.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/DownloadLogsButton.tsx index 0d5843b8515b6..4defc893008b3 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/DownloadLogsButton.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/DownloadLogsButton.tsx @@ -1,11 +1,12 @@ import saveAs from 'file-saver' -import { Download } from 'lucide-react' +import { Download, Settings } from 'lucide-react' import Link from 'next/link' import Papa from 'papaparse' import { useEffect, useState } from 'react' import { toast } from 'sonner' -import { useParams } from 'common' +import { usePathname } from 'next/navigation' +import { IS_PLATFORM, useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useGetUnifiedLogsMutation } from 'data/logs/get-unified-logs' import { @@ -38,6 +39,8 @@ interface DownloadLogsButtonProps { export const DownloadLogsButton = ({ searchParameters }: DownloadLogsButtonProps) => { const { ref } = useParams() + const pathname = usePathname() + const isLogs = pathname?.includes?.('/logs') ?? false const [numRows, setNumRows] = useState(DEFAULT_NUM_ROWS) const [numHours, setNumHours] = useState(DEFAULT_NUM_ROWS) const [selectedFormat, setSelectedFormat] = useState<'csv' | 'json'>() @@ -98,16 +101,21 @@ export const DownloadLogsButton = ({ searchParameters }: DownloadLogsButtonProps tooltip={{ content: { side: 'bottom', text: 'Download logs' } }} /> - - - -

Add a Log Drain

- -
- setSelectedFormat('csv')}> + + {isLogs && IS_PLATFORM && ( + + + +

Add a Log Drain

+ +
+ )} + setSelectedFormat('csv')} className="gap-x-2"> +

Download as CSV

- setSelectedFormat('json')}> + setSelectedFormat('json')} className="gap-x-2"> +

Download as JSON

diff --git a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx index 07b5a69ebd0fa..4237519d1b3cd 100644 --- a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx +++ b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx @@ -2,7 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { Plus } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' -import { useMemo, useState } from 'react' +import { Fragment, useMemo, useState } from 'react' import { toast } from 'sonner' import { useFlag, useParams } from 'common' @@ -275,9 +275,9 @@ const ObservabilityMenu = () => {
{menuItems.map((item, idx) => ( - <> +
-
+
{item.items ? (
{item.title}} /> @@ -305,7 +305,7 @@ const ObservabilityMenu = () => {
) : null}
- + ))} { const { ref } = useParams() + const pathname = usePathname() + const isLogs = pathname?.includes?.('/logs') ?? false // [Joshen] Ensure JSON values are stringified for CSV and Markdown const formattedResults = results.map((row) => { const r = { ...row } @@ -109,12 +113,14 @@ export const DownloadResultsButton = ({ - - - -

Add a Log Drain

- -
+ {isLogs && IS_PLATFORM && ( + + + +

Add a Log Drain

+ +
+ )}

Copy as markdown

diff --git a/apps/studio/pages/project/[ref]/observability/query-performance.tsx b/apps/studio/pages/project/[ref]/observability/query-performance.tsx index 95b8b580f11c7..4ccabcd5b7c2d 100644 --- a/apps/studio/pages/project/[ref]/observability/query-performance.tsx +++ b/apps/studio/pages/project/[ref]/observability/query-performance.tsx @@ -3,7 +3,6 @@ import { parseAsArrayOf, parseAsInteger, parseAsString, useQueryStates } from 'n import { useParams } from 'common' import { useIndexAdvisorStatus } from 'components/interfaces/QueryPerformance/hooks/useIsIndexAdvisorStatus' import { useQueryPerformanceSort } from 'components/interfaces/QueryPerformance/hooks/useQueryPerformanceSort' -import { EnableIndexAdvisorButton } from 'components/interfaces/QueryPerformance/IndexAdvisor/EnableIndexAdvisorButton' import { QueryPerformance } from 'components/interfaces/QueryPerformance/QueryPerformance' import { PRESET_CONFIG, @@ -17,7 +16,6 @@ import DefaultLayout from 'components/layouts/DefaultLayout' import ObservabilityLayout from 'components/layouts/ObservabilityLayout/ObservabilityLayout' import DatabaseSelector from 'components/ui/DatabaseSelector' import { DocsButton } from 'components/ui/DocsButton' -import { FormHeader } from 'components/ui/Forms/FormHeader' import { useReportDateRange } from 'hooks/misc/useReportDateRange' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL } from 'lib/constants' @@ -37,12 +35,13 @@ const QueryPerformanceReport: NextPageWithLayout = () => { handleDatePickerChange, } = useReportDateRange(REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES) - const [{ search: searchQuery, roles, minCalls }] = useQueryStates({ + const [{ search: searchQuery, roles, minCalls, indexAdvisor }] = useQueryStates({ sort: parseAsString, order: parseAsString, search: parseAsString.withDefault(''), roles: parseAsArrayOf(parseAsString).withDefault([]), minCalls: parseAsInteger, + indexAdvisor: parseAsString.withDefault('false'), }) const config = PRESET_CONFIG[Presets.QUERY_PERFORMANCE] @@ -57,37 +56,34 @@ const QueryPerformanceReport: NextPageWithLayout = () => { roles, runIndexAdvisor: isIndexAdvisorEnabled, minCalls: minCalls ?? undefined, + filterIndexAdvisor: indexAdvisor === 'true', }) const isPgStatMonitorEnabled = project?.dbVersion === '17.4.1.076-psml-1' return (
- - - +

Query Performance

+
+ + + {isPgStatMonitorEnabled && ( + + h.text === REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES || + h.text === REPORT_DATERANGE_HELPER_LABELS.LAST_3_HOURS || + h.text === REPORT_DATERANGE_HELPER_LABELS.LAST_24_HOURS + )} + onSubmit={handleDatePickerChange} /> - - {isPgStatMonitorEnabled && ( - - h.text === REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES || - h.text === REPORT_DATERANGE_HELPER_LABELS.LAST_3_HOURS || - h.text === REPORT_DATERANGE_HELPER_LABELS.LAST_24_HOURS - )} - onSubmit={handleDatePickerChange} - /> - )} -
- } - /> + )} +
+
`supabase-dashboard-hotkey-sidebar-${sidebarId}`, + // Index Advisor notice dismissed + INDEX_ADVISOR_NOTICE_DISMISSED: (ref: string) => `index-advisor-notice-dismissed-${ref}`, + /** * COMMON */ From e23175f00be8abd235c3f48ddae55223dd700abe Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Thu, 27 Nov 2025 08:27:50 -0700 Subject: [PATCH 08/14] Feat: E2E tests for AI assistant and log drains (#40844) * updated commands and expose ai key locally * added tests for AI assistant * added OPEN_API_KEY for e2e test suite * updated log drain options * updated README --- .github/workflows/studio-e2e-test.yml | 3 + e2e/studio/README.md | 6 ++ e2e/studio/features/assistant.spec.ts | 46 +++++++++++++++ e2e/studio/features/log-drains.spec.ts | 77 ++++++++++++++++++++++++++ package.json | 2 +- supabase/config.toml | 2 + 6 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 e2e/studio/features/assistant.spec.ts create mode 100644 e2e/studio/features/log-drains.spec.ts diff --git a/.github/workflows/studio-e2e-test.yml b/.github/workflows/studio-e2e-test.yml index 469818511bf7f..8b68f0c0c028a 100644 --- a/.github/workflows/studio-e2e-test.yml +++ b/.github/workflows/studio-e2e-test.yml @@ -20,6 +20,9 @@ jobs: # Require approval only for pull requests from forks environment: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork && 'Studio E2E Tests' || '' }} + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 diff --git a/e2e/studio/README.md b/e2e/studio/README.md index c59cf163e9ccc..6cfe357c67b56 100644 --- a/e2e/studio/README.md +++ b/e2e/studio/README.md @@ -16,6 +16,12 @@ cd e2e/studio pnpm exec playwright install ``` +### Environment Variables + +Some tests require specific environment variables to be set. If these are not set, the tests will be automatically skipped: + +- **`OPENAI_API_KEY`**: Required for the AI Assistant test (`assistant.spec.ts`). Without this variable, the assistant test will be skipped. + --- ## Running the tests diff --git a/e2e/studio/features/assistant.spec.ts b/e2e/studio/features/assistant.spec.ts new file mode 100644 index 0000000000000..c549040e53186 --- /dev/null +++ b/e2e/studio/features/assistant.spec.ts @@ -0,0 +1,46 @@ +import { expect } from '@playwright/test' +import { test } from '../utils/test.js' +import { toUrl } from '../utils/to-url.js' + +test.describe('AI Assistant', async () => { + test('Can send a message to the assistant and receive a response', async ({ page, ref }) => { + // Skip the test if the OPENAI_API_KEY is not set + test.skip(!process.env.OPENAI_API_KEY, 'OPENAI_API_KEY is not set') + + await page.goto(toUrl(`/project/${ref}`)) + + // Wait for the page to load + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + + // Click the assistant button to open the assistant panel + await page.locator('#assistant-trigger').click() + + // Wait for the assistant panel to be visible + await expect(page.getByRole('heading', { name: 'How can I assist you?' })).toBeVisible() + + // Type "hello" in the chat input + const chatInput = page.getByRole('textbox', { name: 'Chat to Postgres...' }) + await chatInput.fill('hello') + + const responsePromise = page.waitForResponse( + (response) => + response.url().includes('/api/ai/sql/generate-v4') && + response.request().method() === 'POST', + { timeout: 60000 } + ) + + // Click the send message button + const sendButton = page.getByRole('button', { name: 'Send message' }) + await sendButton.click() + + // Wait for the API request to complete + const response = await responsePromise + + // Verify the response was successful + expect(response.status()).toBe(200) + + // AI response has values + const body = await response.text() + expect(body).toContain('data') + }) +}) diff --git a/e2e/studio/features/log-drains.spec.ts b/e2e/studio/features/log-drains.spec.ts new file mode 100644 index 0000000000000..6895a4f7d54d7 --- /dev/null +++ b/e2e/studio/features/log-drains.spec.ts @@ -0,0 +1,77 @@ +import { expect } from '@playwright/test' +import { test } from '../utils/test.js' +import { toUrl } from '../utils/to-url.js' + +const LOG_DRAIN_OPTIONS = [ + { + name: 'Custom Endpoint', + buttonText: 'Custom Endpoint Forward logs', + }, + { + name: 'Datadog', + buttonText: 'Datadog Datadog is a', + }, + { + name: 'Loki', + buttonText: 'Loki Loki is an open-source', + }, +] + +test.describe('Log Drains Settings', () => { + test.beforeEach(async ({ page, ref }) => { + // Navigate to the log drains settings page + await page.goto(toUrl(`/project/${ref}/settings/log-drains`)) + + // Wait for the page to load + await expect(page.getByRole('heading', { name: 'Log Drains', level: 1 }), { + message: 'Log Drains heading should be visible', + }).toBeVisible() + }) + + for (const option of LOG_DRAIN_OPTIONS) { + test(`Opens ${option.name} panel when clicked`, async ({ page }) => { + // Click on the log drain option button + const optionButton = page.getByRole('button', { name: option.buttonText }) + await expect(optionButton, { + message: `${option.name} button should be visible`, + }).toBeVisible() + + await optionButton.click() + + // Verify that the "Add destination" dialog opens + const dialog = page.getByRole('dialog', { name: 'Add destination' }) + await expect(dialog, { + message: `Add destination dialog should be visible for ${option.name}`, + }).toBeVisible() + + // Verify the dialog heading + await expect(dialog.getByRole('heading', { name: 'Add destination', level: 2 }), { + message: 'Dialog heading should be visible', + }).toBeVisible() + + // Verify that the Type field shows the correct option + const typeCombobox = dialog.getByRole('combobox').first() + await expect(typeCombobox, { + message: `Type combobox should contain ${option.name}`, + }).toContainText(option.name) + + // Close the dialog by pressing Escape + await page.keyboard.press('Escape') + + // Verify the dialog is closed + await expect(dialog, { + message: 'Dialog should be hidden after pressing Escape', + }).not.toBeVisible() + }) + } + + test('All log drain options are visible on the page', async ({ page }) => { + // Verify all three options are displayed + for (const option of LOG_DRAIN_OPTIONS) { + const optionButton = page.getByRole('button', { name: option.buttonText }) + await expect(optionButton, { + message: `${option.name} option should be visible`, + }).toBeVisible() + } + }) +}) diff --git a/package.json b/package.json index ba2103d98d672..99f4a6d2d0222 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "test:ui-patterns": "turbo run test --filter=ui-patterns", "test:studio": "turbo run test --filter=studio", "test:studio:watch": "turbo run test --filter=studio -- watch", - "e2e:setup:cli": "supabase start --exclude studio && supabase db reset && supabase status --output json > keys.json && node scripts/generateLocalEnv.js", + "e2e:setup:cli": "supabase stop --all --no-backup ; supabase start --exclude studio && supabase db reset && supabase status --output json > keys.json && node scripts/generateLocalEnv.js", "e2e:setup": "SKIP_ASSET_UPLOAD=1 pnpm e2e:setup:cli && NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" pnpm run build:studio && NODE_ENV=test pnpm --prefix ./apps/studio start --port 8082", "e2e": "pnpm --prefix e2e/studio run e2e", "e2e:ui": "pnpm --prefix e2e/studio run e2e:ui", diff --git a/supabase/config.toml b/supabase/config.toml index 177ecf2700031..b3190d03ddf24 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -31,6 +31,8 @@ major_version = 15 # Port to use for Supabase Studio. port = 54323 +openai_api_key = "env(OPENAI_API_KEY)" + # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they # are monitored, and you can view the emails that would have been sent from the web interface. [inbucket] From 866db0bf67cbd2077d9051cd85dbd3afe12a9033 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Thu, 27 Nov 2025 08:29:32 -0700 Subject: [PATCH 09/14] Fix: redirect for load balancer source (#40757) fix redirect for load balancer source --- .../interfaces/Settings/API/ServiceList.tsx | 13 +++++++++++++ .../InfrastructureConfiguration/InstanceNode.tsx | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx index 763bb0cee0757..bb2d6f4cb0724 100644 --- a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx +++ b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx @@ -1,4 +1,6 @@ import { AlertCircle } from 'lucide-react' +import { parseAsString, useQueryState } from 'nuqs' +import { useEffect } from 'react' import { useParams } from 'common' import { ScaffoldSection } from 'components/layouts/Scaffold' @@ -20,6 +22,8 @@ export const ServiceList = () => { const { ref: projectRef } = useParams() const state = useDatabaseSelectorStateSnapshot() + const [querySource, setQuerySource] = useQueryState('source', parseAsString) + const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) const { data: databases, @@ -28,6 +32,12 @@ export const ServiceList = () => { } = useReadReplicasQuery({ projectRef }) const { data: loadBalancers } = useLoadBalancersQuery({ projectRef }) + useEffect(() => { + if (querySource && querySource !== state.selectedDatabaseId) { + state.setSelectedDatabaseId(querySource) + } + }, [querySource, state, projectRef]) + // Get the API service const isCustomDomainActive = customDomainData?.customDomain?.status === 'active' const selectedDatabase = databases?.find((db) => db.identifier === state.selectedDatabaseId) @@ -61,6 +71,9 @@ export const ServiceList = () => { ? [{ id: 'load-balancer', name: 'API Load Balancer' }] : [] } + onSelectId={() => { + setQuerySource(null) + }} /> diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx index 3c3591c519061..10825c032db59 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx @@ -96,7 +96,7 @@ export const LoadBalancerNode = ({ data }: NodeProps) => { - View API URL + View API URL From dfecff7629c84372f8324e1eef26e0bdb8a60036 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Thu, 27 Nov 2025 16:38:10 +0100 Subject: [PATCH 10/14] fix(ui-patterns): responsive with_icon spacing (#40841) --- .../default/example/page-layout-list.tsx | 2 +- .../Auth/OAuthApps/OAuthAppsList.tsx | 2 +- .../PolicyEditorPanel/PolicyTemplates.tsx | 2 +- .../EnumeratedTypes/EnumeratedTypes.tsx | 2 +- .../Database/Extensions/Extensions.tsx | 2 +- .../Functions/FunctionsList/FunctionsList.tsx | 2 +- .../Database/Hooks/HooksList/HooksList.tsx | 2 +- .../interfaces/Database/Indexes/Indexes.tsx | 2 +- .../Database/Migrations/Migrations.tsx | 3 +-- .../Publications/PublicationsList.tsx | 4 ++-- .../Publications/PublicationsTables.tsx | 4 ++-- .../Database/Replication/Destinations.tsx | 22 ++++++++---------- .../ReplicationPipelineStatus.tsx | 6 ++--- .../interfaces/Database/Roles/RolesList.tsx | 2 +- .../interfaces/Database/Tables/ColumnList.tsx | 2 +- .../interfaces/Database/Tables/TableList.tsx | 2 +- .../Triggers/TriggersList/TriggersList.tsx | 2 +- .../EdgeFunctionSecrets.tsx | 2 +- .../components/interfaces/HomePageActions.tsx | 4 ++-- .../Integrations/CronJobs/CronJobsTab.tsx | 2 +- .../Integrations/Queues/QueuesTab.tsx | 2 +- .../Vault/Secrets/SecretsManagement.tsx | 2 +- .../TeamSettings/TeamSettings.tsx | 2 +- .../QueryPerformanceFilterBar.tsx | 2 +- .../Storage/AnalyticsBuckets/index.tsx | 2 +- .../interfaces/Storage/FilesBuckets/index.tsx | 2 +- .../StorageExplorer/FileExplorerHeader.tsx | 14 +++++------ .../StoragePolicies/StoragePolicies.tsx | 4 ++-- .../VectorBucketDetails/index.tsx | 2 +- .../Storage/VectorBuckets/index.tsx | 2 +- .../components/interfaces/Support/Success.tsx | 2 +- apps/studio/pages/organizations.tsx | 4 ++-- .../pages/project/[ref]/auth/policies.tsx | 5 ++-- .../pages/project/[ref]/functions/index.tsx | 4 ++-- packages/ui-patterns/src/DataInputs/Input.tsx | 9 +++++--- .../ui-patterns/src/InnerSideMenu/index.tsx | 2 +- .../src/form/Layout/InputIconContainer.tsx | 23 ++++++++++--------- packages/ui/src/components/Input/Input.tsx | 6 ++--- .../components/InputNumber/InputNumber.tsx | 2 +- .../ui/src/components/Listbox/Listbox2.tsx | 2 +- packages/ui/src/components/Select/Select.tsx | 2 +- packages/ui/src/lib/theme/defaultTheme.ts | 22 ++++++++++++------ 42 files changed, 97 insertions(+), 91 deletions(-) diff --git a/apps/design-system/registry/default/example/page-layout-list.tsx b/apps/design-system/registry/default/example/page-layout-list.tsx index e848704080bb9..7b5dc84a157ba 100644 --- a/apps/design-system/registry/default/example/page-layout-list.tsx +++ b/apps/design-system/registry/default/example/page-layout-list.tsx @@ -71,7 +71,7 @@ export default function PageLayoutList(): React.JSX.Element { } + icon={} className="w-full lg:w-52" />
diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/OAuthAppsList.tsx b/apps/studio/components/interfaces/Auth/OAuthApps/OAuthAppsList.tsx index ef134e5485193..7ee3f465b1ffe 100644 --- a/apps/studio/components/interfaces/Auth/OAuthApps/OAuthAppsList.tsx +++ b/apps/studio/components/interfaces/Auth/OAuthApps/OAuthAppsList.tsx @@ -165,7 +165,7 @@ export const OAuthAppsList = () => { } + icon={} value={filterString} className="w-full lg:w-52" onChange={(e) => setFilterString(e.target.value)} diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx index bdb31dd0d3d39..f72c2f8f65664 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx @@ -59,7 +59,7 @@ export const PolicyTemplates = ({ } + icon={} placeholder="Search templates" value={search} onChange={(event) => setSearch(event.target.value)} diff --git a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx index 98a4bda35eebd..edaa198a6ef20 100644 --- a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx +++ b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx @@ -90,7 +90,7 @@ export const EnumeratedTypes = () => { className="w-full lg:w-52" onChange={(e) => setSearch(e.target.value)} placeholder="Search for a type" - icon={} + icon={} />
diff --git a/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx b/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx index bab02486beddc..ba0a266a22c20 100644 --- a/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx @@ -69,7 +69,7 @@ export const Extensions = () => { value={filterString} onChange={(e) => setFilterString(e.target.value)} className="w-52" - icon={} + icon={} />
diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx index f63d1428eed09..003fb9bbaf4fb 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx @@ -257,7 +257,7 @@ const FunctionsList = () => { } + icon={} value={filterString} className="w-full lg:w-52" onChange={(e) => setFilterString(e.target.value)} diff --git a/apps/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx b/apps/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx index 5c19e17b08e5f..90b11efdc317a 100644 --- a/apps/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx @@ -58,7 +58,7 @@ export const HooksList = ({ } + icon={} value={filterString} className="w-52" onChange={(e) => setFilterString(e.target.value)} diff --git a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx index 9adff97567c8b..51df142e932ac 100644 --- a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx +++ b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx @@ -148,7 +148,7 @@ const Indexes = () => { className="w-full lg:w-52" onChange={(e) => setSearch(e.target.value)} placeholder="Search for an index" - icon={} + icon={} /> {!isSchemaLocked && ( diff --git a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx index 407f2e743975a..3224619fc94f6 100644 --- a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx +++ b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx @@ -89,9 +89,8 @@ const Migrations = () => { value={search} className="w-full lg:w-52" onChange={(e: any) => setSearch(e.target.value)} - icon={} + icon={} /> - diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx index 59d71ef77bf46..589bb0dff4575 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx @@ -100,8 +100,8 @@ export const PublicationsList = () => {
} - className="w-48 pl-8" + icon={} + className="w-48" placeholder="Search for a publication" value={filterString} onChange={(e) => setFilterString(e.target.value)} diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx index b1cdf2a8e9a67..c4c087eac5784 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx @@ -69,8 +69,8 @@ export const PublicationsTables = () => { placeholder="Search for a table" value={filterString} onChange={(e) => setFilterString(e.target.value)} - icon={} - className="w-48 pl-8" + icon={} + className="w-48" />
diff --git a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx index 9aa73a00f2400..5ccee96a93705 100644 --- a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx +++ b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx @@ -14,7 +14,7 @@ import { useReplicationPipelinesQuery } from 'data/replication/pipelines-query' import { useReplicationSourcesQuery } from 'data/replication/sources-query' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { DOCS_URL } from 'lib/constants' -import { Button, cn, Input_Shadcn_ } from 'ui' +import { Button, cn, Input } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns' import { DestinationPanel } from './DestinationPanel/DestinationPanel' import { DestinationRow } from './DestinationRow' @@ -100,18 +100,14 @@ export const Destinations = () => {
-
- - setFilterString(e.target.value)} - /> -
+ } + value={filterString} + className="w-full lg:w-52" + onChange={(e) => setFilterString(e.target.value)} + />
{!!sourceId && ( diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx index 5500217aee10e..28017f217ed7a 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx @@ -215,8 +215,8 @@ export const ReplicationPipelineStatus = () => { )} } - className="pl-7 h-[26px] text-xs" + icon={} + className="text-xs" placeholder="Search for tables" value={searchString} disabled={isPipelineError} @@ -228,7 +228,7 @@ export const ReplicationPipelineStatus = () => { key="close" className="mx-2 cursor-pointer text-foreground" size={14} - strokeWidth={2} + strokeWidth={1.5} onClick={() => setSearchString('')} />, ] diff --git a/apps/studio/components/interfaces/Database/Roles/RolesList.tsx b/apps/studio/components/interfaces/Database/Roles/RolesList.tsx index b33a2d1ce47d2..114a9f6b8766b 100644 --- a/apps/studio/components/interfaces/Database/Roles/RolesList.tsx +++ b/apps/studio/components/interfaces/Database/Roles/RolesList.tsx @@ -84,7 +84,7 @@ export const RolesList = () => { size="tiny" className="w-52" placeholder="Search for a role" - icon={} + icon={} value={filterString} onChange={(event: any) => setFilterString(event.target.value)} actions={ diff --git a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx index 5bd11cf444fd0..2b6be19b18463 100644 --- a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx @@ -82,7 +82,7 @@ export const ColumnList = ({ placeholder="Filter columns" value={filterString} onChange={(e: any) => setFilterString(e.target.value)} - icon={} + icon={} />
{!isSchemaLocked && isTableEntity && ( diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index 4faae844bb4dc..a49aceaa145f1 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -280,7 +280,7 @@ export const TableList = ({ placeholder="Search for a table" value={filterString} onChange={(e) => setFilterString(e.target.value)} - icon={} + icon={} /> {!isSchemaLocked && ( diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx index f38b19e69ab33..2476626499e2c 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx @@ -190,7 +190,7 @@ execute function function_name();`) } + icon={} value={filterString} className="w-full lg:w-52" onChange={(e) => setFilterString(e.target.value)} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx index a2ece5a8550d7..1fed4f148f53d 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx @@ -117,7 +117,7 @@ export const EdgeFunctionSecrets = () => { placeholder="Search for a secret" value={searchString} onChange={(e: any) => setSearchString(e.target.value)} - icon={} + icon={} />
diff --git a/apps/studio/components/interfaces/HomePageActions.tsx b/apps/studio/components/interfaces/HomePageActions.tsx index cd648cc200fa4..4c1bb72842832 100644 --- a/apps/studio/components/interfaces/HomePageActions.tsx +++ b/apps/studio/components/interfaces/HomePageActions.tsx @@ -57,9 +57,9 @@ export const HomePageActions = ({
} + icon={} size="tiny" - className="w-32 md:w-64 pl-8 [&>div>div>div>input]:!pl-7 [&>div>div>div>div]:!pl-2" + className="w-32 md:w-64" value={search} onChange={(event) => setSearch(event.target.value)} actions={[ diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx index 501bbcc32bac5..2fc7fb2c9bc8b 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx @@ -175,7 +175,7 @@ export const CronjobsTab = () => { size="tiny" className="w-52" placeholder="Search for a job" - icon={} + icon={} value={search ?? ''} onChange={(e) => setSearch(e.target.value)} onKeyDown={(e) => { diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx index 611752fd39f95..d7172ee4e2947 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx @@ -63,7 +63,7 @@ export const QueuesTab = () => { size="tiny" className="w-52" placeholder="Search for a queue" - icon={} + icon={} value={search ?? ''} onChange={(e) => setSearch(e.target.value)} onKeyDown={(e) => { diff --git a/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx index b11d7e9260d9a..05a440e41a9a7 100644 --- a/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx +++ b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx @@ -110,7 +110,7 @@ export const SecretsManagement = () => { size="tiny" className="w-52" placeholder="Search by name or key ID" - icon={} + icon={} value={searchValue ?? ''} onChange={(e) => setSearchValue(e.target.value)} actions={[ diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx index dd0fa5499dea7..b7abf61489f2d 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx @@ -40,7 +40,7 @@ export const TeamSettings = () => { } + icon={} value={searchString} onChange={(e: any) => setSearchString(e.target.value)} name="email" diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceFilterBar.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceFilterBar.tsx index 69377159ae96b..81ec966b358ab 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceFilterBar.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceFilterBar.tsx @@ -79,7 +79,7 @@ export const QueryPerformanceFilterBar = ({ } + icon={} value={inputValue} onChange={(e: ChangeEvent) => setInputValue(e.target.value)} name="keyword" diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx index e561e74c2b874..08228cc76cf7d 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx @@ -105,7 +105,7 @@ export const AnalyticsBuckets = () => { placeholder="Search for a bucket" value={filterString} onChange={(e) => setFilterString(e.target.value)} - icon={} + icon={} />
diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/index.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets/index.tsx index cc1690819aad4..57f85cb38367b 100644 --- a/apps/studio/components/interfaces/Storage/FilesBuckets/index.tsx +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/index.tsx @@ -100,7 +100,7 @@ export const FilesBuckets = () => { placeholder="Search for a bucket" value={filterString} onChange={(e) => setFilterString(e.target.value)} - icon={} + icon={} /> diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx index 8948e8dd512b9..d663a791c2a55 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx @@ -454,14 +454,14 @@ export const FileExplorerHeader = ({ size="tiny" autoFocus className="w-52" - icon={} + icon={} actions={[ - } onClick={onCancelSearch} + className="p-0 h-5 w-5" />, ]} placeholder="Search for a file or folder" @@ -471,7 +471,7 @@ export const FileExplorerHeader = ({ /> ) : (