diff --git a/apps/docs/content/guides/platform/billing-on-supabase.mdx b/apps/docs/content/guides/platform/billing-on-supabase.mdx index 868a0f87059d2..c8d502e283d8a 100644 --- a/apps/docs/content/guides/platform/billing-on-supabase.mdx +++ b/apps/docs/content/guides/platform/billing-on-supabase.mdx @@ -39,7 +39,7 @@ Monthly costs for paid plans include a fixed subscription fee based on your chos - [Your monthly invoice](/docs/guides/platform/your-monthly-invoice) - For a detailed breakdown of what a monthly invoice includes - [Manage your usage](/docs/guides/platform/manage-your-usage) - For details on how the different usage items are billed, and how to optimize usage and reduce costs -- [Control your costs]() - For details on how you can control your costs in case unexpected high usage occurs +- [Control your costs](/docs/guides/platform/cost-control) - For details on how you can control your costs in case unexpected high usage occurs ### Compute costs for projects diff --git a/apps/docs/content/guides/realtime/presence.mdx b/apps/docs/content/guides/realtime/presence.mdx index dae2120972f87..972446f8850bb 100644 --- a/apps/docs/content/guides/realtime/presence.mdx +++ b/apps/docs/content/guides/realtime/presence.mdx @@ -20,6 +20,12 @@ When any client subscribes, disconnects, or updates their presence payload, Supa - **`join`** — a new client has started tracking presence - **`leave`** — a client has stopped tracking presence + + +During a `sync` event, you may receive `join` and `leave` events simultaneously, even though no users are actually joining or leaving. This is expected behavior—Presence reconciles its local state with the server state, which can trigger these events as part of the synchronization process. This reflects state reconciliation, not real user movement. + + + The complete presence state returned by `presenceState()` looks like this: ```json diff --git a/apps/docs/content/guides/telemetry/log-drains.mdx b/apps/docs/content/guides/telemetry/log-drains.mdx index f05a4e6f73f5d..525102a8df93f 100644 --- a/apps/docs/content/guides/telemetry/log-drains.mdx +++ b/apps/docs/content/guides/telemetry/log-drains.mdx @@ -220,12 +220,12 @@ with timestamp modified to be parsed by ingestion endpoint. To set up the Axiom log drain, you have to: -1. Create a dataset for ingestion in Axiom dashboard -> Datasets +1. Create a dataset for ingestion in Axiom Console -> Datasets 2. Generate an Axiom API Token with permission to ingest into the created dataset (see [Axiom docs](https://axiom.co/docs/reference/tokens#create-basic-api-token)) 3. Create log drain in [Supabase dashboard](/dashboard/project/_/settings/log-drains), providing: - Name of the dataset - API token -4. Watch for events in the Stream panel of Axiom dashboard +4. Watch for events in the Stream panel of Axiom Console ## Amazon S3 diff --git a/apps/studio/components/grid/SupabaseGrid.tsx b/apps/studio/components/grid/SupabaseGrid.tsx index 13f492af43cb0..be97e1cefa5c7 100644 --- a/apps/studio/components/grid/SupabaseGrid.tsx +++ b/apps/studio/components/grid/SupabaseGrid.tsx @@ -49,6 +49,7 @@ export const SupabaseGrid = ({ const { data: project } = useSelectedProjectQuery() const tableEditorSnap = useTableEditorStateSnapshot() const snap = useTableEditorTableStateSnapshot() + const preflightCheck = !tableEditorSnap.tablesToIgnorePreflightCheck.includes(tableId ?? -1) const gridRef = useRef(null) const [mounted, setMounted] = useState(false) @@ -81,6 +82,7 @@ export const SupabaseGrid = ({ sorts, filters, page: snap.page, + preflightCheck, limit: tableEditorSnap.rowsPerPage, roleImpersonationState: roleImpersonationState as RoleImpersonationState, }, diff --git a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx index 2794e1fe8058c..50d3b92f8b9be 100644 --- a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx +++ b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx @@ -1,7 +1,4 @@ import { THRESHOLD_COUNT } from '@supabase/pg-meta/src/sql/studio/get-count-estimate' -import { ArrowLeft, ArrowRight, HelpCircle, Loader2 } from 'lucide-react' -import { useEffect, useState } from 'react' - import { keepPreviousData } from '@tanstack/react-query' import { useParams } from 'common' import { useTableFilter } from 'components/grid/hooks/useTableFilter' @@ -12,12 +9,15 @@ import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query' import { useTableRowsQuery } from 'data/table-rows/table-rows-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { RoleImpersonationState } from 'lib/role-impersonation' +import { ArrowLeft, ArrowRight, HelpCircle, Loader2 } from 'lucide-react' +import { useEffect, useState } from 'react' import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state' import { useTableEditorStateSnapshot } from 'state/table-editor' import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' + import { DropdownControl } from '../../common/DropdownControl' import { formatEstimatedCount } from './Pagination.utils' @@ -106,6 +106,8 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = const maxPages = Math.ceil(count / tableEditorSnap.rowsPerPage) const totalPages = count > 0 ? maxPages : 1 + const preflightCheck = !tableEditorSnap.tablesToIgnorePreflightCheck.includes(id ?? -1) + // [Joshen] This is only applicable for foreign tables, as we use the number of rows on the page to determine // if we've reached the last page (and hence disable the next button) const { data: rowsData, isPending: isLoadingRows } = useTableRowsQuery( @@ -116,6 +118,7 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = sorts, filters, page: snap.page, + preflightCheck, limit: tableEditorSnap.rowsPerPage, roleImpersonationState: roleImpersonationState as RoleImpersonationState, }, diff --git a/apps/studio/components/grid/components/grid/GridError.tsx b/apps/studio/components/grid/components/grid/GridError.tsx index 05b855083f46c..a44ca11a8f675 100644 --- a/apps/studio/components/grid/components/grid/GridError.tsx +++ b/apps/studio/components/grid/components/grid/GridError.tsx @@ -11,12 +11,18 @@ import { Admonition } from 'ui-patterns' import { HighCostError } from '@/components/ui/HighQueryCost' import { COST_THRESHOLD_ERROR } from '@/data/sql/execute-sql-query' +import { useTableEditorStateSnapshot } from '@/state/table-editor' import { ResponseError } from '@/types' export const GridError = ({ error }: { error?: ResponseError | null }) => { + const { id: _id } = useParams() + const tableId = _id ? Number(_id) : undefined + const { filters } = useTableFilter() const { sorts } = useTableSort() + const snap = useTableEditorTableStateSnapshot() + const tableEditorSnap = useTableEditorStateSnapshot() if (!error) return null @@ -42,6 +48,9 @@ export const GridError = ({ error }: { error?: ResponseError | null }) => { 'Remove any sorts or filters on unindexed columns, or', 'Create indexes for columns that you want to filter or sort on', ]} + onSelectLoadData={() => { + if (!!tableId) tableEditorSnap.setTableToIgnorePreflightCheck(tableId) + }} /> ) } else if (isForeignTableMissingVaultKeyError) { diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index 90721b2c0c12e..14efbcf9d3218 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -31,18 +31,18 @@ import { useTableEditorStateSnapshot } from 'state/table-editor' import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import { Button, + cn, + copyToClipboard, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Separator, - cn, - copyToClipboard, } from 'ui' import { ExportDialog } from './ExportDialog' -import { formatRowsForCSV } from './Header.utils' import { FilterPopover } from './filter/FilterPopover' +import { formatRowsForCSV } from './Header.utils' import { SortPopover } from './sort/SortPopover' export type HeaderProps = { @@ -222,6 +222,9 @@ type RowHeaderProps = { } const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { + const { id: _id } = useParams() + const tableId = _id ? Number(_id) : undefined + const queryClient = useQueryClient() const { data: project } = useSelectedProjectQuery() const tableEditorSnap = useTableEditorStateSnapshot() @@ -237,6 +240,8 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { const [isExporting, setIsExporting] = useState(false) const [showExportModal, setShowExportModal] = useState(false) + const preflightCheck = !tableEditorSnap.tablesToIgnorePreflightCheck.includes(tableId ?? -1) + const { data } = useTableRowsQuery( { projectRef: project?.ref, @@ -245,6 +250,7 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { sorts, filters, page: snap.page, + preflightCheck, limit: tableEditorSnap.rowsPerPage, roleImpersonationState: roleImpersonationState as RoleImpersonationState, }, diff --git a/apps/studio/components/grid/components/header/HeaderNew.tsx b/apps/studio/components/grid/components/header/HeaderNew.tsx index d1bd062b213be..3259451354da9 100644 --- a/apps/studio/components/grid/components/header/HeaderNew.tsx +++ b/apps/studio/components/grid/components/header/HeaderNew.tsx @@ -223,6 +223,9 @@ type RowHeaderProps = { } const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { + const { id: _id } = useParams() + const tableId = _id ? Number(_id) : undefined + const queryClient = useQueryClient() const { data: project } = useSelectedProjectQuery() const tableEditorSnap = useTableEditorStateSnapshot() @@ -238,6 +241,8 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { const [isExporting, setIsExporting] = useState(false) const [showExportModal, setShowExportModal] = useState(false) + const preflightCheck = !tableEditorSnap.tablesToIgnorePreflightCheck.includes(tableId ?? -1) + const { data } = useTableRowsQuery( { projectRef: project?.ref, @@ -246,6 +251,7 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { sorts, filters, page: snap.page, + preflightCheck, limit: tableEditorSnap.rowsPerPage, roleImpersonationState: roleImpersonationState as RoleImpersonationState, }, diff --git a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx index 50631d6e6b168..c1c80df0652c1 100644 --- a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx @@ -236,6 +236,17 @@ export const CreateBranchModal = () => { { onSuccess: () => { if (form.getValues('gitBranchName') !== branchName) return + + // Check if another branch is already linked to this git branch + const existingBranch = (branches ?? []).find((b) => b.git_branch === branchName) + if (existingBranch) { + setIsGitBranchValid(false) + form.setError('gitBranchName', { + message: `Branch "${existingBranch.name}" is already linked to git branch "${branchName}"`, + }) + return + } + setIsGitBranchValid(true) form.clearErrors('gitBranchName') }, @@ -250,7 +261,7 @@ export const CreateBranchModal = () => { } ) }, - [githubConnection, form, checkGithubBranchValidity, repoOwner, repoName] + [githubConnection, form, checkGithubBranchValidity, repoOwner, repoName, branches] ) const onSubmit = (data: z.infer) => { diff --git a/apps/studio/components/interfaces/BranchManagement/EditBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/EditBranchModal.tsx index bb290791dd64e..eeb573b8326c7 100644 --- a/apps/studio/components/interfaces/BranchManagement/EditBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/EditBranchModal.tsx @@ -167,6 +167,19 @@ export const EditBranchModal = ({ branch, visible, onClose }: EditBranchModalPro { onSuccess: () => { if (form.getValues('gitBranchName') !== requested) return + + // Check if another branch is already linked to this git branch + const existingBranch = (branches ?? []).find( + (b) => b.git_branch === branchName && b.id !== branch?.id + ) + if (existingBranch) { + setIsGitBranchValid(false) + form.setError('gitBranchName', { + message: `Branch "${existingBranch.name}" is already linked to git branch "${branchName}"`, + }) + return + } + setIsGitBranchValid(true) form.clearErrors('gitBranchName') }, @@ -181,7 +194,7 @@ export const EditBranchModal = ({ branch, visible, onClose }: EditBranchModalPro } ) }, - [githubConnection, form, checkGithubBranchValidity, repoOwner, repoName] + [githubConnection, form, checkGithubBranchValidity, repoOwner, repoName, branches, branch] ) // Pre-fill form when the modal becomes visible and branch data is available diff --git a/apps/studio/components/interfaces/Database/EnumeratedTypes/DeleteEnumeratedTypeModal.tsx b/apps/studio/components/interfaces/Database/EnumeratedTypes/DeleteEnumeratedTypeModal.tsx deleted file mode 100644 index 37e3b1ab71de0..0000000000000 --- a/apps/studio/components/interfaces/Database/EnumeratedTypes/DeleteEnumeratedTypeModal.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { toast } from 'sonner' - -import { useEnumeratedTypeDeleteMutation } from 'data/enumerated-types/enumerated-type-delete-mutation' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' - -interface DeleteEnumeratedTypeModalProps { - visible: boolean - selectedEnumeratedType?: any - onClose: () => void - onDelete?: () => void -} - -const DeleteEnumeratedTypeModal = ({ - visible, - selectedEnumeratedType, - onClose, - onDelete, -}: DeleteEnumeratedTypeModalProps) => { - const { data: project } = useSelectedProjectQuery() - const { mutate: deleteEnumeratedType, isPending: isDeleting } = useEnumeratedTypeDeleteMutation({ - onSuccess: () => { - toast.success(`Successfully deleted "${selectedEnumeratedType.name}"`) - onClose() - }, - }) - - const onConfirmDeleteType = () => { - if (selectedEnumeratedType === undefined) return console.error('No enumerated type selected') - if (project?.ref === undefined) return console.error('Project ref required') - if (project?.connectionString === undefined) - return console.error('Project connectionString required') - - onDelete?.() - deleteEnumeratedType({ - projectRef: project?.ref, - connectionString: project?.connectionString, - name: selectedEnumeratedType.name, - schema: selectedEnumeratedType.schema, - }) - } - - return ( - - Confirm to delete enumerated type{' '} - {selectedEnumeratedType?.name} - - } - confirmLabel="Confirm delete" - confirmLabelLoading="Deleting..." - onCancel={onClose} - onConfirm={() => onConfirmDeleteType()} - alert={{ - title: 'This action cannot be undone', - description: - 'You will need to re-create the enumerated type if you want to revert the deletion.', - }} - > -

Before deleting this enumerated type, consider:

-
    -
  • - This enumerated type is no longer in use in any tables or functions -
  • -
-
- ) -} - -export default DeleteEnumeratedTypeModal diff --git a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx index 6c4243e6e7569..4b9acd56602e3 100644 --- a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx +++ b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx @@ -1,16 +1,14 @@ -import { Edit, MoreVertical, Search, Trash } from 'lucide-react' -import { parseAsBoolean, useQueryState } from 'nuqs' -import { useRef, useState } from 'react' -import { toast } from 'sonner' - import AlertError from 'components/ui/AlertError' import { DocsButton } from 'components/ui/DocsButton' import SchemaSelector from 'components/ui/SchemaSelector' import { useEnumeratedTypesQuery } from 'data/enumerated-types/enumerated-types-query' -import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' +import { Edit, MoreVertical, Search, Trash } from 'lucide-react' +import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' import { Button, Card, @@ -18,7 +16,6 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - Input, Table, TableBody, TableCell, @@ -26,20 +23,22 @@ import { TableHeader, TableRow, } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning' import CreateEnumeratedTypeSidePanel from './CreateEnumeratedTypeSidePanel' -import DeleteEnumeratedTypeModal from './DeleteEnumeratedTypeModal' import EditEnumeratedTypeSidePanel from './EditEnumeratedTypeSidePanel' +import { useEnumeratedTypeDeleteMutation } from '@/data/enumerated-types/enumerated-type-delete-mutation' export const EnumeratedTypes = () => { const { data: project } = useSelectedProjectQuery() const [search, setSearch] = useState('') const { selectedSchema, setSelectedSchema } = useQuerySchemaState() - const deletingTypeIdRef = useRef(null) const { - data, + data = [], error, isPending: isLoading, isError, @@ -49,25 +48,27 @@ export const EnumeratedTypes = () => { connectionString: project?.connectionString, }) + const { + mutate: deleteEnumeratedType, + isPending: isDeleting, + isSuccess: isSuccessDelete, + } = useEnumeratedTypeDeleteMutation({ + onSuccess: (_, vars) => { + toast.success(`Successfully deleted type "${vars.name}"`) + setSelectedTypeIdToDelete(null) + }, + }) + const [showCreateTypePanel, setShowCreateTypePanel] = useQueryState( 'new', - parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) + parseAsBoolean.withDefault(false) ) - const { value: typeToEdit, setValue: setSelectedTypeIdToEdit } = useQueryStateWithSelect({ - urlKey: 'edit', - select: (id) => (id ? data?.find((type) => type.id.toString() === id) : undefined), - enabled: !!data, - onError: () => toast.error(`Enumerated Type not found`), - }) + const [selectedTypeIdToEdit, setSelectedTypeIdToEdit] = useQueryState('edit', parseAsString) + const typeToEdit = data?.find((type) => type.id.toString() === selectedTypeIdToEdit) - const { value: typeToDelete, setValue: setSelectedTypeIdToDelete } = useQueryStateWithSelect({ - urlKey: 'delete', - select: (id) => (id ? data?.find((type) => type.id.toString() === id) : undefined), - enabled: !!data, - onError: (_error, selectedId) => - handleErrorOnDelete(deletingTypeIdRef, selectedId, `Enumerated Type not found`), - }) + const [selectedTypeIdToDelete, setSelectedTypeIdToDelete] = useQueryState('delete', parseAsString) + const typeToDelete = data?.find((type) => type.id.toString() === selectedTypeIdToDelete) const enumeratedTypes = (data ?? []).filter((type) => type.enums.length > 0) const filteredEnumeratedTypes = @@ -79,6 +80,34 @@ export const EnumeratedTypes = () => { const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema }) + const onConfirmDeleteType = () => { + if (typeToDelete === undefined) return console.error('No enumerated type selected') + if (project?.ref === undefined) return console.error('Project ref required') + if (project?.connectionString === undefined) + return console.error('Project connectionString required') + + deleteEnumeratedType({ + projectRef: project?.ref, + connectionString: project?.connectionString, + name: typeToDelete.name, + schema: typeToDelete.schema, + }) + } + + useEffect(() => { + if (isSuccess && !!selectedTypeIdToEdit && !typeToEdit) { + toast('Type cannot be found') + setSelectedTypeIdToEdit(null) + } + }, [isSuccess, selectedTypeIdToEdit, setSelectedTypeIdToEdit, typeToEdit]) + + useEffect(() => { + if (isSuccess && !!selectedTypeIdToDelete && !typeToDelete && !isSuccessDelete) { + toast('Type cannot be found') + setSelectedTypeIdToDelete(null) + } + }, [isSuccess, selectedTypeIdToDelete, setSelectedTypeIdToDelete, typeToDelete, isSuccessDelete]) + return (
@@ -212,16 +241,33 @@ export const EnumeratedTypes = () => { onClose={() => setSelectedTypeIdToEdit(null)} /> - setSelectedTypeIdToDelete(null)} - onDelete={() => { - if (typeToDelete) { - deletingTypeIdRef.current = typeToDelete.id.toString() - } + title={ + <> + Confirm to delete enumerated type {typeToDelete?.name} + + } + confirmLabel="Confirm delete" + confirmLabelLoading="Deleting..." + onCancel={() => setSelectedTypeIdToDelete(null)} + onConfirm={() => onConfirmDeleteType()} + alert={{ + title: 'This action cannot be undone', + description: + 'You will need to re-create the enumerated type if you want to revert the deletion.', }} - /> + > +

Before deleting this enumerated type, consider:

+
    +
  • + This enumerated type is no longer in use in any tables or functions +
  • +
+
) } diff --git a/apps/studio/components/interfaces/Database/Functions/DeleteFunction.tsx b/apps/studio/components/interfaces/Database/Functions/DeleteFunction.tsx deleted file mode 100644 index fcb4c53f8de59..0000000000000 --- a/apps/studio/components/interfaces/Database/Functions/DeleteFunction.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { TextConfirmModal } from 'components/ui/TextConfirmModalWrapper' -import type { DatabaseFunction } from 'data/database-functions/database-functions-query' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' - -interface DeleteFunctionProps { - func?: DatabaseFunction - visible: boolean - setVisible: (value: string | null) => void - onDelete: (params: { - func: DatabaseFunction - projectRef: string - connectionString?: string | null - }) => void - isLoading: boolean -} - -export const DeleteFunction = ({ - func, - visible, - setVisible, - onDelete, - isLoading, -}: DeleteFunctionProps) => { - const { data: project } = useSelectedProjectQuery() - const { name, schema } = func ?? {} - - async function handleDelete() { - if (!func) return console.error('Function is required') - if (!project) return console.error('Project is required') - - onDelete({ - func, - projectRef: project.ref, - connectionString: project.connectionString, - }) - } - - return ( - setVisible(null)} - onConfirm={handleDelete} - title="Delete this function" - loading={isLoading} - confirmLabel={`Delete function ${name}`} - confirmPlaceholder="Type in name of function" - confirmString={name ?? 'Unknown'} - text={ - <> - This will delete the function{' '} - {name} from the schema{' '} - {schema} - - } - alert={{ title: 'You cannot recover this function once deleted.' }} - /> - ) -} diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx index 57c7a40814924..236dcd7bb2543 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx @@ -1,11 +1,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Search } from 'lucide-react' -import { useRouter } from 'next/router' -import { parseAsBoolean, parseAsJson, useQueryState } from 'nuqs' -import { useRef } from 'react' -import { toast } from 'sonner' - import { useParams } from 'common' +import { useIsInlineEditorEnabled } from 'components/interfaces/Account/Preferences/InlineEditorSettings' +import { CreateFunction } from 'components/interfaces/Database/Functions/CreateFunction' import { ReportsSelectFilter, selectFilterSchema, @@ -20,30 +16,24 @@ import type { DatabaseFunction } from 'data/database-functions/database-function import { useDatabaseFunctionsQuery } from 'data/database-functions/database-functions-query' import { useSchemasQuery } from 'data/database/schemas-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' +import { Search } from 'lucide-react' +import { useRouter } from 'next/router' +import { parseAsBoolean, parseAsJson, parseAsString, useQueryState } from 'nuqs' +import { useEffect } from 'react' +import { toast } from 'sonner' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' +import { useEditorPanelStateSnapshot } from 'state/editor-panel-state' import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' -import { - AiIconAnimation, - Card, - Input, - Table, - TableBody, - TableHead, - TableHeader, - TableRow, -} from 'ui' +import { AiIconAnimation, Card, Table, TableBody, TableHead, TableHeader, TableRow } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + import { ProtectedSchemaWarning } from '../../ProtectedSchemaWarning' import FunctionList from './FunctionList' - -import { useIsInlineEditorEnabled } from 'components/interfaces/Account/Preferences/InlineEditorSettings' -import { CreateFunction } from 'components/interfaces/Database/Functions/CreateFunction' -import { DeleteFunction } from 'components/interfaces/Database/Functions/DeleteFunction' -import { useEditorPanelStateSnapshot } from 'state/editor-panel-state' +import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper' const createFunctionSnippet = `create function function_name() returns void @@ -54,7 +44,7 @@ begin end; $$;` -const FunctionsList = () => { +export const FunctionsList = () => { const router = useRouter() const { search } = useParams() const { data: project } = useSelectedProjectQuery() @@ -68,9 +58,6 @@ const FunctionsList = () => { setInitialPrompt: setEditorPanelInitialPrompt, } = useEditorPanelStateSnapshot() - // Track the ID being deleted to exclude it from error checking - const deletingFunctionIdRef = useRef(null) - const createFunction = () => { setSelectedFunctionIdToDuplicate(null) if (isInlineEditorEnabled) { @@ -109,10 +96,6 @@ const FunctionsList = () => { } } - const deleteFunction = (fn: DatabaseFunction) => { - setSelectedFunctionToDelete(fn.id.toString()) - } - const filterString = search ?? '' // Filters @@ -149,17 +132,18 @@ const FunctionsList = () => { }) const { - data: functions, + data: functions = [], error, isPending: isLoading, isError, + isSuccess, } = useDatabaseFunctionsQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) // Get unique return types from functions in the selected schema - const schemaFunctions = (functions ?? []).filter((fn) => fn.schema === selectedSchema) + const schemaFunctions = functions.filter((fn) => fn.schema === selectedSchema) const uniqueReturnTypes = Array.from(new Set(schemaFunctions.map((fn) => fn.return_type))).sort() // Get security options based on what exists in the selected schema @@ -175,44 +159,66 @@ const FunctionsList = () => { parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) ) - const { setValue: setSelectedFunctionToEdit, value: functionToEdit } = useQueryStateWithSelect({ - urlKey: 'edit', - select: (id: string) => (id ? functions?.find((fn) => fn.id.toString() === id) : undefined), - enabled: !!functions, - onError: () => toast.error(`Function not found`), + const [functionIdToEdit, setSelectedFunctionToEdit] = useQueryState('edit', parseAsString) + const functionToEdit = functions.find((fn) => fn.id.toString() === functionIdToEdit) + + const [functionIdToDuplicate, setSelectedFunctionIdToDuplicate] = useQueryState( + 'duplicate', + parseAsString + ) + const functionToDuplicate = functions.find((fn) => fn.id.toString() === functionIdToDuplicate) + + const [functionIdToDelete, setSelectedFunctionToDelete] = useQueryState('delete', parseAsString) + const functionToDelete = functions.find((fn) => fn.id.toString() === functionIdToDelete) + + const { + mutate: deleteDatabaseFunction, + isPending: isDeletingFunction, + isSuccess: isSuccessDelete, + } = useDatabaseFunctionDeleteMutation({ + onSuccess: (_, variables) => { + toast.success(`Successfully removed function ${variables.func.name}`) + setSelectedFunctionToDelete(null) + }, }) - const { setValue: setSelectedFunctionIdToDuplicate, value: functionToDuplicate } = - useQueryStateWithSelect({ - urlKey: 'duplicate', - select: (id: string) => { - if (!id) return undefined - const original = functions?.find((fn) => fn.id.toString() === id) - return original ? { ...original, name: `${original.name}_duplicate` } : undefined - }, - enabled: !!functions, - onError: () => toast.error(`Function not found`), - }) + const onDeleteFunction = () => { + if (!project) return console.error('Project is required') + if (!functionToDelete) return console.error('Function is required') - const { setValue: setSelectedFunctionToDelete, value: functionToDelete } = - useQueryStateWithSelect({ - urlKey: 'delete', - select: (id: string) => (id ? functions?.find((fn) => fn.id.toString() === id) : undefined), - enabled: !!functions, - onError: (_error, selectedId) => - handleErrorOnDelete(deletingFunctionIdRef, selectedId, `Function not found`), + deleteDatabaseFunction({ + func: functionToDelete, + projectRef: project.ref, + connectionString: project.connectionString, }) + } - const { mutate: deleteDatabaseFunction, isPending: isDeletingFunction } = - useDatabaseFunctionDeleteMutation({ - onSuccess: (_, variables) => { - toast.success(`Successfully removed function ${variables.func.name}`) - setSelectedFunctionToDelete(null) - }, - onError: () => { - deletingFunctionIdRef.current = null - }, - }) + useEffect(() => { + if (isSuccess && !!functionIdToEdit && !functionToEdit) { + toast('Function not found') + setSelectedFunctionToEdit(null) + } + }, [functionIdToEdit, functionToEdit, isSuccess, setSelectedFunctionToEdit]) + + useEffect(() => { + if (isSuccess && !!functionIdToDuplicate && !functionToDuplicate) { + toast('Function not found') + setSelectedFunctionIdToDuplicate(null) + } + }, [functionIdToDuplicate, functionToDuplicate, isSuccess, setSelectedFunctionIdToDuplicate]) + + useEffect(() => { + if (isSuccess && !!functionIdToDelete && !functionToDelete && !isSuccessDelete) { + toast('Function not found') + setSelectedFunctionToDelete(null) + } + }, [ + functionIdToDelete, + functionToDelete, + isSuccess, + isSuccessDelete, + setSelectedFunctionToDelete, + ]) if (isLoading) return if (isError) return @@ -351,7 +357,7 @@ const FunctionsList = () => { securityFilter={securityFilter ?? []} duplicateFunction={duplicateFunction} editFunction={editFunction} - deleteFunction={deleteFunction} + deleteFunction={(fn) => setSelectedFunctionToDelete(fn.id.toString())} functions={functions ?? []} /> @@ -360,37 +366,37 @@ const FunctionsList = () => {
)} - {/* Create Function */} - { - setShowCreateFunctionForm(false) - }} - /> - - {/* Edit or Duplicate Function */} { + setShowCreateFunctionForm(false) setSelectedFunctionToEdit(null) setSelectedFunctionIdToDuplicate(null) }} isDuplicating={!!functionToDuplicate} /> - [0]) => { - deletingFunctionIdRef.current = params.func.id.toString() - deleteDatabaseFunction(params) - }} - isLoading={isDeletingFunction} + onCancel={() => setSelectedFunctionToDelete(null)} + onConfirm={onDeleteFunction} + title="Delete this function" + loading={isDeletingFunction} + confirmLabel={`Delete function ${functionToDelete?.name}`} + confirmPlaceholder="Type in name of function" + confirmString={functionToDelete?.name ?? 'Unknown'} + text={ + <> + This will delete the function{' '} + {functionToDelete?.name}{' '} + from the schema{' '} + {functionToDelete?.schema} + + } + alert={{ title: 'You cannot recover this function once deleted.' }} /> ) } - -export default FunctionsList diff --git a/apps/studio/components/interfaces/Database/Roles/DeleteRoleModal.tsx b/apps/studio/components/interfaces/Database/Roles/DeleteRoleModal.tsx deleted file mode 100644 index 4bc9d70584d18..0000000000000 --- a/apps/studio/components/interfaces/Database/Roles/DeleteRoleModal.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { PostgresRole } from '@supabase/postgres-meta' -import { toast } from 'sonner' - -import { useDatabaseRoleDeleteMutation } from 'data/database-roles/database-role-delete-mutation' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { Modal } from 'ui' - -interface DeleteRoleModalProps { - role: PostgresRole - visible: boolean - onClose: () => void - onDelete?: () => void -} - -export const DeleteRoleModal = ({ role, visible, onClose, onDelete }: DeleteRoleModalProps) => { - const { data: project } = useSelectedProjectQuery() - - const { mutate: deleteDatabaseRole, isPending: isDeleting } = useDatabaseRoleDeleteMutation({ - onSuccess: () => { - toast.success(`Successfully deleted role: ${role.name}`) - onClose() - }, - }) - - const deleteRole = async () => { - if (!project) return console.error('Project is required') - if (!role) return console.error('Failed to delete role: role is missing') - onDelete?.() - deleteDatabaseRole({ - projectRef: project.ref, - connectionString: project.connectionString, - id: role.id, - }) - } - - return ( - Confirm to delete role "{role?.name}"} - loading={isDeleting} - > - -

- This will automatically revoke any membership of this role in other roles, and this action - cannot be undone. -

-
-
- ) -} diff --git a/apps/studio/components/interfaces/Database/Roles/RolesList.tsx b/apps/studio/components/interfaces/Database/Roles/RolesList.tsx index c8cc91ad8a86c..70748b7adea2f 100644 --- a/apps/studio/components/interfaces/Database/Roles/RolesList.tsx +++ b/apps/studio/components/interfaces/Database/Roles/RolesList.tsx @@ -1,24 +1,25 @@ -import type { PostgresRole } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { NoSearchResults } from 'components/ui/NoSearchResults' -import SparkBar from 'components/ui/SparkBar' +import { SparkBar } from 'components/ui/SparkBar' import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query' import { useMaxConnectionsQuery } from 'data/database/max-connections-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { partition, sortBy } from 'lodash' import { Plus, Search, X } from 'lucide-react' -import { parseAsBoolean, useQueryState } from 'nuqs' -import { useRef, useState } from 'react' -import { Badge, Button, Input, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import { Badge, Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' +import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' import { CreateRolePanel } from './CreateRolePanel' -import { DeleteRoleModal } from './DeleteRoleModal' import { RoleRow } from './RoleRow' import { RoleRowSkeleton } from './RoleRowSkeleton' import { SUPABASE_ROLES } from './Roles.constants' +import { useDatabaseRoleDeleteMutation } from '@/data/database-roles/database-role-delete-mutation' type SUPABASE_ROLE = (typeof SUPABASE_ROLES)[number] @@ -27,7 +28,6 @@ export const RolesList = () => { const [filterString, setFilterString] = useState('') const [filterType, setFilterType] = useState<'all' | 'active'>('all') - const deletingRoleIdRef = useRef(null) const { can: canUpdateRoles } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, @@ -40,25 +40,34 @@ export const RolesList = () => { }) const maxConnectionLimit = maxConnData?.maxConnections - const { data, isPending: isLoading } = useDatabaseRolesQuery({ + const { + data, + isPending: isLoading, + isSuccess, + } = useDatabaseRolesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) + const { + mutate: deleteDatabaseRole, + isPending: isDeleting, + isSuccess: isSuccessDelete, + } = useDatabaseRoleDeleteMutation({ + onSuccess: () => { + toast.success(`Successfully deleted role`) + setSelectedRoleIdToDelete(null) + }, + }) + const [isCreatingRole, setIsCreatingRole] = useQueryState( 'new', - parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) + parseAsBoolean.withDefault(false) ) - const { setValue: setSelectedRoleIdToDelete, value: roleToDelete } = useQueryStateWithSelect({ - urlKey: 'delete', - select: (id: string) => (id ? data?.find((role) => role.id.toString() === id) : undefined), - enabled: !!data, - onError: (_error, selectedId) => - handleErrorOnDelete(deletingRoleIdRef, selectedId, `Database Role not found`), - }) - + const [selectedRoleIdToDelete, setSelectedRoleIdToDelete] = useQueryState('delete', parseAsString) const roles = sortBy(data ?? [], (r) => r.name.toLocaleLowerCase()) + const roleToDelete = roles?.find((role) => role.id.toString() === selectedRoleIdToDelete) const filteredRoles = ( filterType === 'active' ? roles.filter((role) => role.activeConnections > 0) : roles @@ -76,6 +85,23 @@ export const RolesList = () => { (r) => -r.activeConnections ) + const deleteRole = async () => { + if (!project) return console.error('Project is required') + if (!roleToDelete) return console.error('Failed to delete role: role is missing') + deleteDatabaseRole({ + projectRef: project.ref, + connectionString: project.connectionString, + id: roleToDelete.id, + }) + } + + useEffect(() => { + if (isSuccess && !!selectedRoleIdToDelete && !roleToDelete && !isSuccessDelete) { + toast('Role cannot be found') + setSelectedRoleIdToDelete(null) + } + }, [isSuccess, selectedRoleIdToDelete, roleToDelete, isSuccessDelete, setSelectedRoleIdToDelete]) + return ( <>
@@ -86,7 +112,7 @@ export const RolesList = () => { placeholder="Search for a role" icon={} value={filterString} - onChange={(event: any) => setFilterString(event.target.value)} + onChange={(event) => setFilterString(event.target.value)} actions={ filterString && (
@@ -297,7 +300,7 @@ export const SchemaGraph = () => { )} {isSuccessTables && ( <> - {tables.length === 0 ? ( + {hasNoTables ? (
{ - const [selectedTrigger, setSelectedTrigger] = useState() - const deletingTriggerIdRef = useRef(null) const { data: project } = useSelectedProjectQuery() const { openSidebar } = useSidebarManagerSnapshot() const { selectedSchema, setSelectedSchema } = useQuerySchemaState() @@ -67,9 +64,10 @@ export const TriggersList = () => { data.filter((a) => !protectedSchemas.find((s) => s.name === a.schema)).length > 0 const { - data: triggers, + data: triggers = [], error, isPending, + isSuccess, isError, } = useDatabaseTriggersQuery({ projectRef: project?.ref, @@ -85,42 +83,26 @@ export const TriggersList = () => { 'new', parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) ) - const { setValue: setTriggerToEdit, value: triggerToEdit } = useQueryStateWithSelect({ - urlKey: 'edit', - select: (id: string) => (id ? triggers?.find((fn) => fn.id.toString() === id) : undefined), - enabled: !!triggers, - onError: () => toast.error(`Trigger not found`), - }) - const { setValue: setTriggerToDuplicate, value: triggerToDuplicate } = useQueryStateWithSelect({ - urlKey: 'duplicate', - select: (id: string) => { - if (!id) return undefined - const original = triggers?.find((trigger) => trigger.id.toString() === id) - return original ? { ...original, name: `${original.name}_duplicate` } : undefined - }, - enabled: !!triggers, - onError: () => toast.error(`Trigger not found`), - }) + const [triggerIdToEdit, setTriggerToEdit] = useQueryState('edit', parseAsString) + const triggerToEdit = triggers?.find((fn) => fn.id.toString() === triggerIdToEdit) - const { setValue: setTriggerToDelete, value: triggerToDelete } = useQueryStateWithSelect({ - urlKey: 'delete', - select: (id: string) => (id ? triggers?.find((fn) => fn.id.toString() === id) : undefined), - enabled: !!triggers, - onError: (_error, selectedId) => - handleErrorOnDelete(deletingTriggerIdRef, selectedId, `Database Trigger not found`), - }) + const [triggerIdToDuplicate, setTriggerToDuplicate] = useQueryState('duplicate', parseAsString) + const triggerToDuplicate = triggers?.find((fn) => fn.id.toString() === triggerIdToDuplicate) - const { mutate: deleteDatabaseTrigger, isPending: isDeletingTrigger } = - useDatabaseTriggerDeleteMutation({ - onSuccess: (_, variables) => { - toast.success(`Successfully removed ${variables.trigger.name}`) - setTriggerToDelete(null) - }, - onError: () => { - deletingTriggerIdRef.current = null - }, - }) + const [triggerIdToDelete, setTriggerToDelete] = useQueryState('delete', parseAsString) + const triggerToDelete = triggers?.find((fn) => fn.id.toString() === triggerIdToDelete) + + const { + mutate: deleteDatabaseTrigger, + isPending: isDeletingTrigger, + isSuccess: isSuccessDelete, + } = useDatabaseTriggerDeleteMutation({ + onSuccess: (_, variables) => { + toast.success(`Successfully removed ${variables.trigger.name}`) + setTriggerToDelete(null) + }, + }) const createTrigger = () => { setTriggerToDuplicate(null) @@ -135,7 +117,6 @@ execute function function_name();`) } openSidebar(SIDEBAR_KEYS.EDITOR_PANEL) } else { - setSelectedTrigger(undefined) setShowCreateTriggerForm(true) } } @@ -165,10 +146,41 @@ execute function function_name();`) } } - const deleteTrigger = (trigger: PostgresTrigger) => { - setTriggerToDelete(trigger.id.toString()) + const onDeleteTrigger = () => { + if (!project) return console.error('Project is required') + if (!triggerToDelete) return console.error('Trigger ID is required') + + deleteDatabaseTrigger({ + projectRef: project.ref, + connectionString: project.connectionString, + trigger: triggerToDelete, + }) } + const schemaTriggers = triggers.filter((x) => x.schema === selectedSchema) + const tables = Array.from(new Set(schemaTriggers.map((x) => x.table))).sort() + + useEffect(() => { + if (isSuccess && !!triggerIdToEdit && !triggerToEdit) { + toast('Trigger not found') + setTriggerToEdit(null) + } + }, [isSuccess, setTriggerToEdit, triggerIdToEdit, triggerToEdit]) + + useEffect(() => { + if (isSuccess && !!triggerIdToDuplicate && !triggerToDuplicate) { + toast('Trigger not found') + setTriggerToDuplicate(null) + } + }, [isSuccess, triggerIdToDuplicate, triggerToDuplicate, setTriggerToDuplicate]) + + useEffect(() => { + if (isSuccess && !!triggerIdToDelete && !triggerToDelete && !isSuccessDelete) { + toast('Trigger not found') + setTriggerToDelete(null) + } + }, [isSuccess, triggerIdToDelete, triggerToDelete, isSuccessDelete, setTriggerToDelete]) + if (isPending) { return } @@ -177,9 +189,6 @@ execute function function_name();`) return } - const schemaTriggers = triggers.filter((x) => x.schema === selectedSchema) - const tables = Array.from(new Set(schemaTriggers.map((x) => x.table))).sort() - return ( <>
@@ -260,7 +269,7 @@ execute function function_name();`) setTriggerToDelete(trigger.id.toString())} /> @@ -269,34 +278,37 @@ execute function function_name();`) )}
- { - setShowCreateTriggerForm(false) - }} - isDuplicatingTrigger={false} - /> - { + setShowCreateTriggerForm(false) setTriggerToEdit(null) setTriggerToDuplicate(null) }} isDuplicatingTrigger={!!triggerToDuplicate} /> - [0]) => { - deletingTriggerIdRef.current = params.trigger.id.toString() - deleteDatabaseTrigger(params) + onCancel={() => setTriggerToDelete(null)} + onConfirm={() => onDeleteTrigger()} + title="Delete this trigger" + loading={isDeletingTrigger} + confirmLabel={`Delete trigger ${triggerToDelete?.name}`} + confirmPlaceholder="Type in name of trigger" + confirmString={triggerToDelete?.name ?? ''} + text={ + <> + This will delete your trigger called{' '} + {triggerToDelete?.name} of schema{' '} + {triggerToDelete?.schema} + + } + alert={{ + title: 'You cannot recover this trigger once deleted.', }} - isLoading={isDeletingTrigger} /> ) diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.CleanupNotice.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.CleanupNotice.tsx index 2f59734a55860..97155317b1a78 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.CleanupNotice.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.CleanupNotice.tsx @@ -16,6 +16,9 @@ import { SelectItem_Shadcn_, SelectTrigger_Shadcn_, SelectValue_Shadcn_, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' import { Admonition } from 'ui-patterns/admonition' @@ -24,15 +27,15 @@ import { useCronJobsCleanupActions, type BatchDeletionProgress, } from './CronJobsTab.useCleanupActions' +import { InlineLinkClassName } from '@/components/ui/InlineLink' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' interface CronJobRunDetailsOverflowNoticeV2Props { + queryCost?: number refetchJobs: () => void } -export const CronJobRunDetailsOverflowNoticeV2 = ( - props: CronJobRunDetailsOverflowNoticeV2Props -) => { +export const CronJobRunDetailsOverflowNotice = (props: CronJobRunDetailsOverflowNoticeV2Props) => { return ( { const { data: project } = useSelectedProjectQuery() @@ -75,7 +79,10 @@ const CronJobRunDetailsOverflowDialog = ({ - + event.preventDefault()} + > Last run for cron jobs omitted for overview @@ -89,8 +96,17 @@ const CronJobRunDetailsOverflowDialog = ({

- However, the join was skipped as the estimated query cost exceeds safety thresholds, - likely due to the size of{' '} + However, the join was skipped as the{' '} + + estimated query cost + +

Estimated cost: {queryCost?.toLocaleString()}

+

+ Determined via the EXPLAIN command +

+ + {' '} + exceeds safety thresholds, likely due to the size of{' '} cron.job_run_details table.

diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx index 53510a42f3bfb..807d37b8a8e0e 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx @@ -18,7 +18,7 @@ import { LoadingLine, Sheet, SheetContent } from 'ui' import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' import { formatCronJobColumns } from './CronJobs.utils' -import { CronJobRunDetailsOverflowNoticeV2 } from './CronJobsTab.CleanupNotice' +import { CronJobRunDetailsOverflowNotice } from './CronJobsTab.CleanupNotice' import { CronJobsTabDataGrid } from './CronJobsTab.DataGrid' import { CronJobsTabHeader } from './CronJobsTab.Header' import { useCronJobsData } from './CronJobsTab.useCronJobsData' @@ -171,7 +171,12 @@ export const CronjobsTab = () => { onCreateJob={onOpenCreateJobSheet} /> - {grid.isMinimal && } + {grid.isMinimal && ( + + )} + queryCost?: number isSuccess: boolean isLoading: boolean error: ResponseError | null @@ -130,6 +131,7 @@ export function useCronJobsData({ return { grid: { rows: cronJobs, + queryCost: cronJobsError?.metadata?.cost, error: useMinimalQuery ? cronJobsMinimalError : cronJobsError, isSuccess: useMinimalQuery ? isCronJobsMinimalSuccess : isCronJobsSuccess, isLoading: useMinimalQuery ? isCronJobsMinimalLoading : isCronJobsLoading, diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx index dc65c2d2a1e06..2df6201e6bcf6 100644 --- a/apps/studio/components/interfaces/Sidebar.tsx +++ b/apps/studio/components/interfaces/Sidebar.tsx @@ -1,11 +1,3 @@ -import { AnimatePresence, motion, MotionProps } from 'framer-motion' -import { isUndefined } from 'lodash' -import { Blocks, Boxes, ChartArea, PanelLeftDashed, Receipt, Settings, Users } from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { ComponentProps, ComponentPropsWithoutRef, FC, ReactNode, useEffect } from 'react' - -import { PROJECT_STATUS } from '@/lib/constants' import { LOCAL_STORAGE_KEYS, useFlag, useIsMFAEnabled, useParams } from 'common' import { generateOtherRoutes, @@ -15,6 +7,7 @@ import { } from 'components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils' import { ProjectIndexPageLink } from 'data/prefetchers/project.$ref' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { AnimatePresence, motion, MotionProps } from 'framer-motion' import { useHideSidebar } from 'hooks/misc/useHideSidebar' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useLints } from 'hooks/misc/useLints' @@ -22,6 +15,11 @@ import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Home } from 'icons' +import { isUndefined } from 'lodash' +import { Blocks, Boxes, ChartArea, PanelLeftDashed, Receipt, Settings, Users } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { ComponentProps, ComponentPropsWithoutRef, FC, ReactNode, useEffect } from 'react' import { useAppStateSnapshot } from 'state/app-state' import { Button, @@ -43,6 +41,7 @@ import { Sidebar as SidebarPrimitive, useSidebar, } from 'ui' + import { Route } from '../ui/ui.types' import { useIsAPIDocsSidePanelEnabled, @@ -161,12 +160,10 @@ export function SideBarNavLink({ route, active, onClick, - disabled, ...props }: { route: Route active?: boolean - disabled?: boolean onClick?: () => void } & ComponentPropsWithoutRef) { const [sidebarBehaviour] = useLocalStorageQuery( @@ -175,7 +172,7 @@ export function SideBarNavLink({ ) const buttonProps = { - disabled, + disabled: route.disabled, tooltip: sidebarBehaviour === 'closed' ? route.label : '', isActive: active, className: cn('text-sm', sidebarBehaviour === 'open' ? '!px-2' : ''), @@ -194,7 +191,7 @@ export function SideBarNavLink({ return ( - {route.link && !disabled ? ( + {route.link && !route.disabled ? ( {content} @@ -205,16 +202,12 @@ export function SideBarNavLink({ ) } -const ActiveDot = (errorArray: any[], warningArray: any[]) => { +const ActiveDot = ({ hasErrors, hasWarnings }: { hasErrors: boolean; hasWarnings: boolean }) => { return (
0 - ? 'bg-destructive-600' - : warningArray.length > 0 - ? 'bg-warning-600' - : 'bg-transparent' + hasErrors ? 'bg-destructive-600' : hasWarnings ? 'bg-warning-600' : 'bg-transparent' )} /> ) @@ -230,8 +223,6 @@ const ProjectLinks = () => { const showReports = useIsFeatureEnabled('reports:all') const { mutate: sendEvent } = useSendEventMutation() - const isProjectActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY - const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled() const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview() @@ -283,7 +274,6 @@ const ProjectLinks = () => { {toolRoutes.map((route, i) => ( @@ -294,7 +284,6 @@ const ProjectLinks = () => { {productRoutes.map((route, i) => ( @@ -322,41 +311,36 @@ const ProjectLinks = () => { return ( ) } else if (route.key === 'advisors') { return (
- {isProjectActive && ActiveDot(errorLints, securityLints)} - + {!route.disabled && ( + 0} + hasWarnings={securityLints.length > 0} + /> + )} +
) } else { return ( - + ) } })} @@ -440,7 +424,6 @@ const OrganizationLinks = () => { {navMenuItems.map((item, i) => ( { link: item.href, key: item.label, icon: item.icon, + disabled: disableAccessMfa, }} /> ))} diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx index 4a06b218dfe43..995e6915d8730 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx @@ -10,6 +10,7 @@ import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' import { Blocks, FileText, Lightbulb, List, Settings, Telescope } from 'lucide-react' export const generateToolRoutes = (ref?: string, project?: Project, features?: {}): Route[] => { + const isProjectActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP const buildingUrl = `/project/${ref}` @@ -17,6 +18,7 @@ export const generateToolRoutes = (ref?: string, project?: Project, features?: { { key: 'editor', label: 'Table Editor', + disabled: !isProjectActive, icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/editor`), linkElement: , @@ -24,6 +26,7 @@ export const generateToolRoutes = (ref?: string, project?: Project, features?: { { key: 'sql', label: 'SQL Editor', + disabled: !isProjectActive, icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/sql`), }, @@ -58,6 +61,7 @@ export const generateProductRoutes = ( { key: 'database', label: 'Database', + disabled: !isProjectActive, icon: , link: ref && @@ -73,6 +77,7 @@ export const generateProductRoutes = ( { key: 'auth', label: 'Authentication', + disabled: !isProjectActive, icon: , link: ref && @@ -90,6 +95,7 @@ export const generateProductRoutes = ( { key: 'storage', label: 'Storage', + disabled: !isProjectActive, icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/storage/files`), }, @@ -100,6 +106,7 @@ export const generateProductRoutes = ( { key: 'functions', label: 'Edge Functions', + disabled: false, icon: , link: ref && `/project/${ref}/functions`, }, @@ -110,6 +117,7 @@ export const generateProductRoutes = ( { key: 'realtime', label: 'Realtime', + disabled: !isProjectActive, icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/realtime/inspector`), }, @@ -123,6 +131,7 @@ export const generateOtherRoutes = ( project?: Project, features?: { unifiedLogs?: boolean; showReports?: boolean; apiDocsSidePanel?: boolean } ): Route[] => { + const isProjectActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP const buildingUrl = `/project/${ref}` @@ -135,6 +144,7 @@ export const generateOtherRoutes = ( { key: 'advisors', label: 'Advisors', + disabled: !isProjectActive, icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/advisors/security`), }, @@ -143,6 +153,7 @@ export const generateOtherRoutes = ( { key: 'observability', label: 'Observability', + disabled: !isProjectActive, icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/observability`), }, @@ -151,20 +162,16 @@ export const generateOtherRoutes = ( { key: 'logs', label: 'Logs', + disabled: false, icon: , - link: - ref && - (isProjectBuilding - ? buildingUrl - : unifiedLogsEnabled - ? `/project/${ref}/logs` - : `/project/${ref}/logs/explorer`), + link: ref && (unifiedLogsEnabled ? `/project/${ref}/logs` : `/project/${ref}/logs/explorer`), }, ...(apiDocsSidePanelEnabled ? [ { key: 'api', label: 'API Docs', + disabled: !isProjectActive, icon: , link: ref && @@ -175,6 +182,7 @@ export const generateOtherRoutes = ( { key: 'integrations', label: 'Integrations', + disabled: !isProjectActive, icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/integrations`), }, @@ -192,6 +200,7 @@ export const generateSettingsRoutes = (ref?: string, project?: Project): Route[] ref && (IS_PLATFORM ? `/project/${ref}/settings/general` : `/project/${ref}/settings/log-drains`), items: settingsMenu, + disabled: false, }, ] } diff --git a/apps/studio/components/layouts/ProjectLayout/index.tsx b/apps/studio/components/layouts/ProjectLayout/index.tsx index 660c784aa4193..5c63046e4dca2 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.tsx @@ -1,7 +1,3 @@ -import Head from 'next/head' -import { useRouter } from 'next/router' -import { forwardRef, Fragment, PropsWithChildren, ReactNode, useEffect } from 'react' - import { mergeRefs, useParams } from 'common' import { CreateBranchModal } from 'components/interfaces/BranchManagement/CreateBranchModal' import { ProjectAPIDocs } from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs' @@ -13,10 +9,14 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { withAuth } from 'hooks/misc/withAuth' import { usePHFlag } from 'hooks/ui/useFlag' import { PROJECT_STATUS } from 'lib/constants' +import Head from 'next/head' +import { useRouter } from 'next/router' +import { forwardRef, Fragment, PropsWithChildren, ReactNode, useEffect } from 'react' import { useAppStateSnapshot } from 'state/app-state' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { cn, LogoLoader, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav' + import { useEditorType } from '../editors/EditorsLayout.hooks' import { useSetMainScrollContainer } from '../MainScrollContainerContext' import BuildingState from './BuildingState' @@ -35,27 +35,27 @@ import { UpgradingState } from './UpgradingState' // [Joshen] This is temporary while we unblock users from managing their project // if their project is not responding well for any reason. Eventually needs a bit of an overhaul const routesToIgnoreProjectDetailsRequest = [ + '/project/[ref]/settings/infrastructure', + '/project/[ref]/settings/addons', '/project/[ref]/settings/general', '/project/[ref]/database/settings', '/project/[ref]/storage/settings', - '/project/[ref]/settings/infrastructure', - '/project/[ref]/settings/addons', ] const routesToIgnoreDBConnection = [ '/project/[ref]/branches', - '/project/[ref]/database/backups/scheduled', - '/project/[ref]/database/backups/pitr', - '/project/[ref]/settings/addons', + '/project/[ref]/database/backups', + '/project/[ref]/settings', '/project/[ref]/functions', + '/project/[ref]/logs', ] const routesToIgnorePostgrestConnection = [ - '/project/[ref]/reports', '/project/[ref]/settings/general', - '/project/[ref]/database/settings', '/project/[ref]/settings/infrastructure', '/project/[ref]/settings/addons', + '/project/[ref]/database/settings', + '/project/[ref]/reports', ] export interface ProjectLayoutProps { @@ -108,7 +108,8 @@ export const ProjectLayout = forwardRef
- {/* autoSaveId="project-layout" */} {productMenu && ( router.pathname.includes(x)) const requiresPostgrestConnection = !routesToIgnorePostgrestConnection.includes(router.pathname) const requiresProjectDetails = !routesToIgnoreProjectDetailsRequest.includes(router.pathname) @@ -304,11 +296,11 @@ const ContentWrapper = ({ isLoading, isBlocking = true, children }: ContentWrapp // handle redirect to home for building state const shouldRedirectToHomeForBuilding = - isHomeNew && requiresDbConnection && isProjectBuilding && !isBranchesPage && !isHomePage + isProjectBuilding && requiresDbConnection && isHomeNew && !isHomePage // We won't be showing the building state with the new home page const shouldShowBuildingState = - requiresDbConnection && isProjectBuilding && !isBranchesPage && !(isHomeNew && isHomePage) + isProjectBuilding && requiresDbConnection && !(isHomeNew && isHomePage) useEffect(() => { if (shouldRedirectToHomeForBuilding && ref) { @@ -352,7 +344,7 @@ const ContentWrapper = ({ isLoading, isBlocking = true, children }: ContentWrapp return } - if (isProjectRestoreFailed && !isBackupsPage && !isEdgeFunctionPages) { + if (requiresDbConnection && isProjectRestoreFailed) { return } diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx index cd82c7aa6a5f5..17320fc9d33ef 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx @@ -1,8 +1,7 @@ -import { ArrowUpRight } from 'lucide-react' - import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' import type { Project } from 'data/projects/project-detail-query' import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' +import { ArrowUpRight } from 'lucide-react' import type { Organization } from 'types' export const generateSettingsMenu = ( @@ -37,13 +36,7 @@ export const generateSettingsMenu = ( } const isProjectActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY - const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP - const buildingUrl = `/project/${ref}` - const authEnabled = features?.auth ?? true - const authProvidersEnabled = features?.authProviders ?? true - const edgeFunctionsEnabled = features?.edgeFunctions ?? true - const storageEnabled = features?.storage ?? true const legacyJwtKeysEnabled = features?.legacyJwtKeys ?? true const billingEnabled = features?.billing ?? true @@ -67,7 +60,7 @@ export const generateSettingsMenu = ( { name: 'Infrastructure', key: 'infrastructure', - url: isProjectBuilding ? buildingUrl : `/project/${ref}/settings/infrastructure`, + url: `/project/${ref}/settings/infrastructure`, items: [], disabled: !isProjectActive, }, @@ -118,14 +111,15 @@ export const generateSettingsMenu = ( { name: 'Data API', key: 'api', - url: isProjectBuilding ? buildingUrl : `/project/${ref}/integrations/data_api/overview`, + url: `/project/${ref}/integrations/data_api/overview`, items: [], rightIcon: , + disabled: !isProjectActive, }, { name: 'Vault', key: 'vault', - url: isProjectBuilding ? buildingUrl : `/project/${ref}/integrations/vault/overview`, + url: `/project/${ref}/integrations/vault/overview`, items: [], rightIcon: , label: 'Beta', diff --git a/apps/studio/components/ui/HighQueryCost.tsx b/apps/studio/components/ui/HighQueryCost.tsx index e6a73ac0ec551..ca266201e1c41 100644 --- a/apps/studio/components/ui/HighQueryCost.tsx +++ b/apps/studio/components/ui/HighQueryCost.tsx @@ -24,17 +24,26 @@ import { ResponseError } from '@/types' interface HighQueryCostErrorProps { error: ResponseError suggestions?: string[] + onSelectLoadData?: () => void } -export const HighCostError = ({ error, suggestions }: HighQueryCostErrorProps) => { - // [Joshen] The CTA could be to use a read replica to query or something? +export const HighCostError = ({ + error, + suggestions, + onSelectLoadData, +}: HighQueryCostErrorProps) => { return ( - +
+ {!!onSelectLoadData && ( + + )} + +
) } @@ -45,9 +54,7 @@ const HighQueryCostDialog = ({ error, suggestions = [] }: HighQueryCostErrorProp return ( - + event.preventDefault()}> @@ -73,19 +80,33 @@ const HighQueryCostDialog = ({ error, suggestions = [] }: HighQueryCostErrorProp

{' '} - is high and could place significant load on the database. + is high and could place significant load on the database with high disk I/O or CPU + usage.

- {suggestions.length > 0 && ( -
-

You may check the following to ensure that the query cost is lower

-
    - {suggestions.map((x) => ( -
  • {x}
  • - ))} -
-
- )} + + {suggestions.length > 0 && ( + <> + + +

+ Suggested steps +

+ + {suggestions.length > 0 && ( +
+

You may check the following to lower the cost of the query

+
    + {suggestions.map((x) => ( +
  • {x}
  • + ))} +
+
+ )} +
+ + )} + ) } + +const LoadDataWarningDialog = ({ + error, + onSelectLoadData, +}: { + error: ResponseError + onSelectLoadData: () => void +}) => { + const metadata = error.metadata + + return ( + + + + + event.preventDefault()}> + + Confirm to proceed loading data + + Preventive measure to mitigate impacting the database + + + + +

+ The query to load your table's data was initially skipped as its{' '} + + estimated cost + +

Estimated cost: {metadata?.cost.toLocaleString()}

+

+ Determined via the EXPLAIN command +

+ + {' '} + is high and could place significant load on the database with high disk I/O or CPU + usage. +

+ +

+ You may proceed to run the query, and we'll suppress this warning for this table for the + rest of this browser session. +

+
+ + + + + + +
+
+ ) +} diff --git a/apps/studio/components/ui/SparkBar.tsx b/apps/studio/components/ui/SparkBar.tsx index a6334e4474232..af57cc554ebcc 100644 --- a/apps/studio/components/ui/SparkBar.tsx +++ b/apps/studio/components/ui/SparkBar.tsx @@ -13,7 +13,7 @@ interface SparkBarProps { borderClass?: string } -const SparkBar = ({ +export const SparkBar = ({ max = 100, value = 0, barClass = 'bg-foreground', diff --git a/apps/studio/data/sql/execute-sql-query.ts b/apps/studio/data/sql/execute-sql-query.ts index 7182f5ae52cbf..79626f6c0ed9b 100644 --- a/apps/studio/data/sql/execute-sql-query.ts +++ b/apps/studio/data/sql/execute-sql-query.ts @@ -21,7 +21,7 @@ import { * Reckon we ensure that the dashboard just caps query costs at "heavy", so that it doesn't impact the DB for other queries * (e.g from the user's application) */ -const COST_THRESHOLD = 100_000 +const COST_THRESHOLD = 200_000 export const COST_THRESHOLD_ERROR = 'Query cost exceeds threshold' export type ExecuteSqlVariables = { diff --git a/apps/studio/data/table-rows/table-rows-query.ts b/apps/studio/data/table-rows/table-rows-query.ts index 926edb3742de5..8332f6929cc13 100644 --- a/apps/studio/data/table-rows/table-rows-query.ts +++ b/apps/studio/data/table-rows/table-rows-query.ts @@ -1,6 +1,6 @@ import { Query, type QueryFilter } from '@supabase/pg-meta/src/query' import { getTableRowsSql } from '@supabase/pg-meta/src/query/table-row-query' -import { type QueryClient, useQuery, useQueryClient } from '@tanstack/react-query' +import { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import { IS_PLATFORM } from 'common' import { parseSupaTable } from 'components/grid/SupabaseGrid.utils' import { Filter, Sort, SupaRow, SupaTable } from 'components/grid/types' @@ -16,11 +16,12 @@ import { isRoleImpersonationEnabled } from 'state/role-impersonation-state' import { ResponseError, UseCustomQueryOptions } from 'types' import { handleError } from '../fetchers' -import { ExecuteSqlError, executeSql } from '../sql/execute-sql-query' +import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' import { tableRowKeys } from './keys' import { formatFilterValue } from './utils' +import { timeout } from '@/lib/helpers' -export interface GetTableRowsArgs { +interface GetTableRowsArgs { table?: SupaTable filters?: Filter[] sorts?: Sort[] @@ -87,10 +88,6 @@ function getRetryAfter(error: any): number | undefined { return undefined } -async function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - export async function executeWithRetry( fn: () => Promise, maxRetries: number = 3, @@ -106,7 +103,7 @@ export async function executeWithRetry( const retryAfter = getRetryAfter(error) const delayMs = retryAfter ? retryAfter * 1000 : baseDelay * Math.pow(2, attempt) - await sleep(delayMs) + await timeout(delayMs) continue } throw error @@ -267,7 +264,7 @@ export const fetchAllTableRows = async ({ if (result.length < rowsPerPage) break - await sleep(THROTTLE_DELAY) + await timeout(THROTTLE_DELAY) } catch (error) { throw new Error( `Error fetching all table rows: ${error instanceof Error ? error.message : 'Unknown error'}` @@ -294,7 +291,7 @@ export const fetchAllTableRows = async ({ if (result.length < rowsPerPage) break - await sleep(THROTTLE_DELAY) + await timeout(THROTTLE_DELAY) } catch (error) { throw new Error( `Error fetching all table rows: ${error instanceof Error ? error.message : 'Unknown error'}` @@ -306,19 +303,20 @@ export const fetchAllTableRows = async ({ return rows.filter((row) => row[ROLE_IMPERSONATION_NO_RESULTS] !== 1) } -export type TableRows = { rows: SupaRow[] } +type TableRows = { rows: SupaRow[] } -export type TableRowsVariables = Omit & { +type TableRowsVariables = Omit & { queryClient: QueryClient projectRef?: string connectionString?: string | null tableId?: number + preflightCheck?: boolean } export type TableRowsData = TableRows -export type TableRowsError = ExecuteSqlError +type TableRowsError = ExecuteSqlError -export async function getTableRows( +async function getTableRows( { queryClient, projectRef, @@ -329,6 +327,7 @@ export async function getTableRows( sorts, limit, page, + preflightCheck = false, }: TableRowsVariables, signal?: AbortSignal ) { @@ -375,7 +374,7 @@ export async function getTableRows( sql, queryKey: ['table-rows', table?.id], isRoleImpersonationEnabled: isRoleImpersonationEnabled(roleImpersonationState?.role), - preflightCheck: true, + preflightCheck, }, signal ) @@ -395,6 +394,7 @@ export const useTableRowsQuery = ( { enabled = true, ...options }: UseCustomQueryOptions = {} ) => { const queryClient = useQueryClient() + return useQuery({ queryKey: tableRowKeys.tableRows(projectRef, { table: { id: tableId }, diff --git a/apps/studio/hooks/misc/useQueryStateWithSelect.ts b/apps/studio/hooks/misc/useQueryStateWithSelect.ts deleted file mode 100644 index 31cbe6fa6bd8c..0000000000000 --- a/apps/studio/hooks/misc/useQueryStateWithSelect.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { parseAsString, useQueryState } from 'nuqs' -import { MutableRefObject, useEffect, useMemo } from 'react' -import { toast } from 'sonner' - -/** - * Hook for managing URL query parameters with a custom select function and error handling. - * - * @param enabled - Whether error handling is active (shows error when selectedId exists but select returns undefined) - * @param urlKey - The query parameter key (e.g., 'edit', 'delete') - * @param select - Function to transform the selected ID into the desired value (returns undefined if not found) - * @param onError - Callback invoked when enabled is true and selectedId exists but select returns undefined - * - * @returns Object with: - * - value: The result of select(selectedId) or undefined - * - setValue: Function to set/clear the selected ID in the URL - * - * @deprecated Avoid using this hook, and use nuqs directly - * Refer to this PR for more information: https://github.com/supabase/supabase/pull/41380 - * as well as context on how to refactor to remove usage of this hook - */ -export function useQueryStateWithSelect({ - enabled, - urlKey, - select, - onError, -}: { - enabled: boolean - urlKey: string - select: (id: string) => T | undefined - onError: (error: Error, selectedId: string) => void -}) { - const [selectedId, setSelectedId] = useQueryState( - urlKey, - parseAsString.withOptions({ history: 'push', clearOnDefault: true }) - ) - - const value = useMemo(() => (selectedId ? select(selectedId) : undefined), [selectedId, select]) - - useEffect(() => { - if (enabled && selectedId && !value) { - onError(new Error(`not found`), selectedId) - setSelectedId(null) - } - }, [enabled, onError, selectedId, setSelectedId, value]) - - return { - value, - setValue: setSelectedId as (value: string | null) => void, - } -} - -export const handleErrorOnDelete = ( - deletingIdRef: MutableRefObject, - selectedId: string, - errorMessage: string -) => { - if (selectedId !== deletingIdRef.current) { - toast.error(errorMessage) - } else { - deletingIdRef.current = null - } -} diff --git a/apps/studio/pages/project/[ref]/database/functions.tsx b/apps/studio/pages/project/[ref]/database/functions.tsx index e5706d0ba12e9..18f07bf40768e 100644 --- a/apps/studio/pages/project/[ref]/database/functions.tsx +++ b/apps/studio/pages/project/[ref]/database/functions.tsx @@ -1,8 +1,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - -import FunctionsList from 'components/interfaces/Database/Functions/FunctionsList/FunctionsList' +import { FunctionsList } from 'components/interfaces/Database/Functions/FunctionsList/FunctionsList' import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' -import DefaultLayout from 'components/layouts/DefaultLayout' +import { DefaultLayout } from 'components/layouts/DefaultLayout' import { DocsButton } from 'components/ui/DocsButton' import NoPermission from 'components/ui/NoPermission' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' diff --git a/apps/studio/state/table-editor-table.tsx b/apps/studio/state/table-editor-table.tsx index 65c41ddd676a2..4641a5548abb8 100644 --- a/apps/studio/state/table-editor-table.tsx +++ b/apps/studio/state/table-editor-table.tsx @@ -1,15 +1,15 @@ import { useFlag } from 'common' +import { TableIndexAdvisorProvider } from 'components/grid/context/TableIndexAdvisorContext' import { loadTableEditorStateFromLocalStorage, parseSupaTable, saveTableEditorStateToLocalStorageDebounced, } from 'components/grid/SupabaseGrid.utils' -import { TableIndexAdvisorProvider } from 'components/grid/context/TableIndexAdvisorContext' import { Filter, SupaRow } from 'components/grid/types' import { getInitialGridColumns } from 'components/grid/utils/column' import { getGridColumns } from 'components/grid/utils/gridColumns' import { Entity } from 'data/table-editor/table-editor-types' -import { PropsWithChildren, createContext, useContext, useEffect, useRef } from 'react' +import { createContext, PropsWithChildren, useContext, useEffect, useRef } from 'react' import { CalculatedColumn } from 'react-data-grid' import { proxy, ref, subscribe, useSnapshot } from 'valtio' import { proxySet } from 'valtio/utils' @@ -20,6 +20,7 @@ export const createTableEditorTableState = ({ projectRef, table: originalTable, editable = true, + preflightCheck = true, onAddColumn, onExpandJSONEditor, onExpandTextEditor, @@ -28,6 +29,7 @@ export const createTableEditorTableState = ({ table: Entity /** If set to true, render an additional "+" column to support adding a new column in the grid editor */ editable?: boolean + preflightCheck?: boolean onAddColumn: () => void onExpandJSONEditor: (column: string, row: SupaRow) => void onExpandTextEditor: (column: string, row: SupaRow) => void @@ -171,6 +173,9 @@ export const createTableEditorTableState = ({ clearFilters: () => { state.filters = [] }, + + preflightCheck, + setPreflightCheck: (value: boolean) => (state.preflightCheck = value), }) return state diff --git a/apps/studio/state/table-editor.tsx b/apps/studio/state/table-editor.tsx index 297e977fde7d9..9f78e710c751d 100644 --- a/apps/studio/state/table-editor.tsx +++ b/apps/studio/state/table-editor.tsx @@ -10,15 +10,15 @@ import { generateTableChangeKey } from 'components/grid/utils/queueOperationUtil import { ForeignKey } from 'components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.types' import type { EditValue } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types' import type { TableField } from 'components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.types' -import { PropsWithChildren, createContext, useContext } from 'react' +import { createContext, PropsWithChildren, useContext } from 'react' import type { Dictionary } from 'types' import { proxy, useSnapshot } from 'valtio' import { NewQueuedOperation, + QueuedOperationType, type OperationQueueState, type QueueStatus, - QueuedOperationType, } from './table-editor-operation-queue.types' export const TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE = 100 @@ -331,6 +331,16 @@ export const createTableEditorState = () => { }) return state.operationQueue.operations.some((op) => op.id === key) }, + + /** + * Toggle the preflight check behaviour for each table + */ + tablesToIgnorePreflightCheck: [] as number[], + setTableToIgnorePreflightCheck: (id: number) => { + const set = new Set(state.tablesToIgnorePreflightCheck) + set.add(id) + state.tablesToIgnorePreflightCheck = [...set] + }, }) return state diff --git a/e2e/studio/features/database.spec.ts b/e2e/studio/features/database.spec.ts index 01bc77deecf54..936f29e68e8d1 100644 --- a/e2e/studio/features/database.spec.ts +++ b/e2e/studio/features/database.spec.ts @@ -644,9 +644,9 @@ test.describe.serial('Database', () => { if (exists) { await page.getByRole('button', { name: databaseRoleName }).getByRole('button').click() await page.getByRole('menuitem', { name: 'Delete' }).click() - await page.getByRole('button', { name: 'Confirm' }).click() + await page.getByRole('button', { name: 'Submit' }).click() await expect( - page.getByText(`Successfully deleted role: ${databaseRoleName}`), + page.getByText(`Successfully deleted role`), 'Delete confirmation toast should be visible' ).toBeVisible({ timeout: 50000 }) } @@ -667,10 +667,10 @@ test.describe.serial('Database', () => { await page.getByRole('button', { name: databaseRoleName }).getByRole('button').click() await page.getByRole('menuitem', { name: 'Delete' }).click() const roleDeleteWait = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=roles-delete') - await page.getByRole('button', { name: 'Confirm' }).click() + await page.getByRole('button', { name: 'Submit' }).click() await roleDeleteWait await expect( - page.getByText(`Successfully deleted role: ${databaseRoleName}`), + page.getByText(`Successfully deleted role`), 'Delete confirmation toast should be visible' ).toBeVisible({ timeout: 50000 }) }) @@ -723,7 +723,7 @@ test.describe.serial('Database Enumerated Types', () => { await page.getByRole('menuitem', { name: 'Delete type' }).click() await page.getByRole('heading', { name: 'Confirm to delete enumerated' }).click() await page.getByRole('button', { name: 'Confirm delete' }).click() - await expect(page.getByText(`Successfully deleted "${databaseEnumName}"`)).toBeVisible() + await expect(page.getByText(`Successfully deleted type "${databaseEnumName}"`)).toBeVisible() } // create a new enum @@ -758,7 +758,7 @@ test.describe.serial('Database Enumerated Types', () => { await page.getByRole('menuitem', { name: 'Delete type' }).click() await page.getByRole('heading', { name: 'Confirm to delete enumerated' }).click() await page.getByRole('button', { name: 'Confirm delete' }).click() - await expect(page.getByText(`Successfully deleted "${databaseEnumName}"`)).toBeVisible({ + await expect(page.getByText(`Successfully deleted type "${databaseEnumName}"`)).toBeVisible({ timeout: 50000, }) })