mirror of
https://github.com/supabase/supabase.git
synced 2026-09-09 11:30:17 +08:00
## Problem When the table editor showed a "Failed to load tables" or "Failed to load schemas" error (for example, when the underlying database or API gateway is unhealthy), there was no working way to restart the project from that error state. Restarting only worked by navigating to Project Settings. ## Fix "Failed to load tables" goes through the existing `ErrorMatcher` classification system, which only showed troubleshooting steps (including a restart action) for connection-timeout errors. Added an `ERROR_MAPPINGS` entry for the unclassified/generic API error case, reusing the existing `RestartDatabaseTroubleshootingSection` and `RestartProjectDialog` components already used for connection timeouts. "Failed to load schemas" (in the shared `SchemaSelector`, used across the table editor and several Database pages) only offered a retry. Added a "Restart database" button next to it, wired to the same `RestartProjectDialog`. ## How to test - In the table editor, trigger a table-load failure that isn't a connection timeout (any generic API error). The error card should now show a "Try restarting your project" step with a working restart action. - Open the schema selector while schemas fail to load (e.g. mock a 503 from the schemas query). A "Restart database" button should appear next to "Reload schemas" and open the restart confirmation dialog. - `apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.test.tsx` and `apps/studio/components/ui/SchemaSelector.test.tsx` cover both cases. FE-4054 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added database restart guidance when schema loading fails. * Added options to reload schemas or restart the database, including a confirmation prompt. * Added troubleshooting guidance for unclassified table-loading errors. * **Bug Fixes** * Improved error handling by displaying relevant fallback guidance for unknown errors while preserving classified troubleshooting instructions. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
|
import { keepPreviousData } from '@tanstack/react-query'
|
|
import { useParams } from 'common'
|
|
import { Filter, Plus } from 'lucide-react'
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { Button, Checkbox, Label, Popover, PopoverContent, PopoverTrigger } from 'ui'
|
|
import {
|
|
InnerSideBarEmptyPanel,
|
|
InnerSideBarFilters,
|
|
InnerSideBarFilterSearchInput,
|
|
InnerSideBarFilterSortDropdown,
|
|
InnerSideBarFilterSortDropdownItem,
|
|
} from 'ui-patterns/InnerSideMenu'
|
|
|
|
import { useTableEditorTabsCleanUp } from '../Tabs/Tabs.utils'
|
|
import { EntityListItem } from './EntityListItem'
|
|
import { TableMenuEmptyState } from './TableMenuEmptyState'
|
|
import { TableMenuFilterEmptyState } from './TableMenuFilterEmptyState'
|
|
import { ExportDialog } from '@/components/grid/components/header/ExportDialog'
|
|
import { parseSupaTable } from '@/components/grid/SupabaseGrid.utils'
|
|
import { SupaTable } from '@/components/grid/types'
|
|
import { ProtectedSchemaWarning } from '@/components/interfaces/Database/ProtectedSchemaWarning'
|
|
import { ErrorMatcher } from '@/components/interfaces/ErrorHandling/ErrorMatcher'
|
|
import { RestartTroubleshootingFallback } from '@/components/interfaces/ErrorHandling/RestartTroubleshootingFallback'
|
|
import { EditorMenuListSkeleton } from '@/components/layouts/TableEditorLayout/EditorMenuListSkeleton'
|
|
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
|
import { InfiniteListDefault, LoaderForIconMenuItems } from '@/components/ui/InfiniteList'
|
|
import { SchemaSelector } from '@/components/ui/SchemaSelector'
|
|
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
|
|
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
|
|
import { useEntityTypesQuery } from '@/data/entity-types/entity-types-infinite-query'
|
|
import { useTableApiAccessQuery } from '@/data/privileges/table-api-access-query'
|
|
import { getTableEditor, useTableEditorQuery } from '@/data/table-editor/table-editor-query'
|
|
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
|
import { useLocalStorage } from '@/hooks/misc/useLocalStorage'
|
|
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
|
|
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
|
import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
|
|
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
|
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
|
import { useTableEditorStateSnapshot } from '@/state/table-editor'
|
|
|
|
type Sort = 'alphabetical' | 'grouped-alphabetical'
|
|
|
|
export const TableEditorMenu = () => {
|
|
const { id: _id, ref: projectRef } = useParams()
|
|
const id = _id ? Number(_id) : undefined
|
|
const snap = useTableEditorStateSnapshot()
|
|
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
|
|
|
|
const [searchText, setSearchText] = useState<string>('')
|
|
const [isSchemaDropdownOpen, setIsSchemaDropdownOpen] = useState(false)
|
|
const [tableToExport, setTableToExport] = useState<SupaTable>()
|
|
const [visibleTypes, setVisibleTypes] = useState<string[]>(Object.values(ENTITY_TYPE))
|
|
const [sort, setSort] = useLocalStorage<Sort>('table-editor-sort', 'alphabetical')
|
|
|
|
const { data: project } = useSelectedProjectQuery()
|
|
const {
|
|
data,
|
|
isLoading,
|
|
isSuccess,
|
|
isError,
|
|
error,
|
|
hasNextPage,
|
|
isFetchingNextPage,
|
|
fetchNextPage,
|
|
} = useEntityTypesQuery(
|
|
{
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString,
|
|
schemas: [selectedSchema],
|
|
search: searchText.trim() || undefined,
|
|
sort,
|
|
filterTypes: visibleTypes,
|
|
},
|
|
{
|
|
enabled: visibleTypes.length > 0,
|
|
placeholderData: Boolean(searchText) ? keepPreviousData : undefined,
|
|
}
|
|
)
|
|
|
|
const entityTypes = useMemo(
|
|
() => data?.pages.flatMap((page) => page.data.entities),
|
|
[data?.pages]
|
|
)
|
|
const entityNames = useMemo(() => entityTypes?.map((entity) => entity.name) ?? [], [entityTypes])
|
|
|
|
const { data: apiAccessByTableName } = useTableApiAccessQuery(
|
|
{
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString ?? undefined,
|
|
schemaName: selectedSchema,
|
|
tableNames: entityNames,
|
|
},
|
|
{ enabled: Boolean(selectedSchema && entityNames.length > 0) }
|
|
)
|
|
|
|
const { can: canCreateTables } = useAsyncCheckPermissions(
|
|
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
|
'tables'
|
|
)
|
|
|
|
const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
|
|
|
|
const { data: selectedTable } = useTableEditorQuery({
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString,
|
|
id,
|
|
})
|
|
|
|
if (selectedTable?.schema && !selectedSchema) {
|
|
setSelectedSchema(selectedTable.schema)
|
|
}
|
|
|
|
useShortcut(SHORTCUT_IDS.TABLE_EDITOR_FOCUS_SCHEMA, () => setIsSchemaDropdownOpen(true), {
|
|
registerInCommandMenu: true,
|
|
})
|
|
|
|
const tableEditorTabsCleanUp = useTableEditorTabsCleanUp()
|
|
|
|
const onSelectExportCLI = useCallback(
|
|
async (id: number) => {
|
|
const table = await getTableEditor({
|
|
id: id,
|
|
projectRef,
|
|
connectionString: project?.connectionString,
|
|
})
|
|
const supaTable = table && parseSupaTable(table)
|
|
setTableToExport(supaTable)
|
|
},
|
|
[project?.connectionString, projectRef]
|
|
)
|
|
|
|
const getItemKey = useCallback(
|
|
(index: number) => {
|
|
const item = entityTypes?.[index]
|
|
return item?.id ? String(item.id) : `table-editor-entity-${index}`
|
|
},
|
|
[entityTypes]
|
|
)
|
|
|
|
const entityProps = useMemo(
|
|
() => ({
|
|
projectRef: project?.ref!,
|
|
id: Number(id),
|
|
isLocked: isSchemaLocked,
|
|
onExportCLI: () => onSelectExportCLI(Number(id)),
|
|
apiAccessMap: apiAccessByTableName,
|
|
}),
|
|
[project?.ref, id, isSchemaLocked, onSelectExportCLI, apiAccessByTableName]
|
|
)
|
|
|
|
const hasFiltersApplied = visibleTypes.length !== 5
|
|
|
|
useEffect(() => {
|
|
// Clean up tabs + recent items for any tables that might have been removed outside of the dashboard session
|
|
if (entityTypes && !searchText) {
|
|
tableEditorTabsCleanUp({ schemas: [selectedSchema], entities: entityTypes })
|
|
}
|
|
}, [entityTypes, searchText, selectedSchema, tableEditorTabsCleanUp])
|
|
|
|
return (
|
|
<>
|
|
<div className="flex flex-col grow gap-5 pt-5 h-full">
|
|
<div className="flex flex-col gap-y-1.5">
|
|
<ShortcutTooltip
|
|
shortcutId={SHORTCUT_IDS.TABLE_EDITOR_FOCUS_SCHEMA}
|
|
label="Switch schema"
|
|
side="bottom"
|
|
open={isSchemaDropdownOpen ? false : undefined}
|
|
>
|
|
<SchemaSelector
|
|
className="mx-4"
|
|
selectedSchemaName={selectedSchema}
|
|
onSelectSchema={(name: string) => {
|
|
setSearchText('')
|
|
setSelectedSchema(name)
|
|
setIsSchemaDropdownOpen(false)
|
|
}}
|
|
onSelectCreateSchema={() => {
|
|
snap.onAddSchema()
|
|
setIsSchemaDropdownOpen(false)
|
|
}}
|
|
open={isSchemaDropdownOpen}
|
|
onOpenChange={setIsSchemaDropdownOpen}
|
|
/>
|
|
</ShortcutTooltip>
|
|
|
|
<div className="grid gap-3 mx-4">
|
|
{!isSchemaLocked ? (
|
|
<ButtonTooltip
|
|
block
|
|
title="Create a new table"
|
|
name="New table"
|
|
disabled={!canCreateTables}
|
|
size="tiny"
|
|
icon={<Plus size={14} strokeWidth={1.5} className="text-foreground-muted" />}
|
|
variant="default"
|
|
className="justify-start"
|
|
onClick={() => snap.onAddTable()}
|
|
tooltip={{
|
|
content: {
|
|
side: 'bottom',
|
|
text: !canCreateTables
|
|
? 'You need additional permissions to create tables'
|
|
: undefined,
|
|
},
|
|
}}
|
|
>
|
|
New table
|
|
</ButtonTooltip>
|
|
) : (
|
|
<ProtectedSchemaWarning size="sm" schema={selectedSchema} entity="table" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="grow min-h-0 flex flex-col gap-2 pb-4">
|
|
<InnerSideBarFilters className="mx-2">
|
|
<InnerSideBarFilterSearchInput
|
|
name="search-tables"
|
|
value={searchText}
|
|
placeholder="Search tables..."
|
|
aria-labelledby="Search tables"
|
|
onChange={(e) => setSearchText(e.target.value)}
|
|
>
|
|
<InnerSideBarFilterSortDropdown
|
|
value={sort}
|
|
onValueChange={(value) => setSort(value as Sort)}
|
|
>
|
|
<InnerSideBarFilterSortDropdownItem
|
|
key="alphabetical"
|
|
value="alphabetical"
|
|
className="flex gap-2"
|
|
>
|
|
Alphabetical
|
|
</InnerSideBarFilterSortDropdownItem>
|
|
<InnerSideBarFilterSortDropdownItem
|
|
key="grouped-alphabetical"
|
|
value="grouped-alphabetical"
|
|
>
|
|
Entity Type
|
|
</InnerSideBarFilterSortDropdownItem>
|
|
</InnerSideBarFilterSortDropdown>
|
|
</InnerSideBarFilterSearchInput>
|
|
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<ButtonTooltip
|
|
className="h-[32px] md:h-[28px] px-1.5"
|
|
variant={hasFiltersApplied ? 'default' : 'dashed'}
|
|
icon={<Filter />}
|
|
aria-label="Filter"
|
|
tooltip={{ content: { side: 'bottom', text: 'Filter' } }}
|
|
/>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="p-0 w-60" side="bottom" align="center">
|
|
<div className="px-3 pt-3 pb-2 flex flex-col gap-y-2">
|
|
<p className="text-xs">Filter entity types</p>
|
|
<div className="flex flex-col">
|
|
{Object.entries(ENTITY_TYPE).map(([key, value]) => (
|
|
<div key={key} className="group flex items-center justify-between py-0.5">
|
|
<div className="flex items-center gap-x-2">
|
|
<Checkbox
|
|
id={key}
|
|
name={key}
|
|
checked={visibleTypes.includes(value)}
|
|
onCheckedChange={() => {
|
|
if (visibleTypes.includes(value)) {
|
|
setVisibleTypes(visibleTypes.filter((y) => y !== value))
|
|
} else {
|
|
setVisibleTypes(visibleTypes.concat([value]))
|
|
}
|
|
}}
|
|
/>
|
|
<Label htmlFor={key} className="capitalize text-xs">
|
|
{key.toLowerCase().replace('_', ' ')}
|
|
</Label>
|
|
</div>
|
|
<Button
|
|
size="tiny"
|
|
variant="default"
|
|
onClick={() => setVisibleTypes([value])}
|
|
className="transition opacity-0 group-hover:opacity-100 h-auto px-1 py-0.5"
|
|
>
|
|
Select only
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</InnerSideBarFilters>
|
|
|
|
{visibleTypes.length === 0 && (
|
|
<TableMenuFilterEmptyState
|
|
onResetFilters={() => setVisibleTypes(Object.values(ENTITY_TYPE))}
|
|
/>
|
|
)}
|
|
|
|
{isLoading && <EditorMenuListSkeleton />}
|
|
|
|
{isError && (
|
|
<ErrorMatcher
|
|
title="Failed to load tables"
|
|
error={error ?? 'Failed to load tables'}
|
|
supportFormParams={{ projectRef: project?.ref }}
|
|
className="mx-4 mt-3"
|
|
fallback={<RestartTroubleshootingFallback />}
|
|
/>
|
|
)}
|
|
|
|
{isSuccess && (
|
|
<>
|
|
{searchText.length === 0 && (entityTypes?.length ?? 0) <= 0 && (
|
|
<TableMenuEmptyState />
|
|
)}
|
|
{searchText.length > 0 && (entityTypes?.length ?? 0) <= 0 && (
|
|
<InnerSideBarEmptyPanel
|
|
className="mx-2"
|
|
title="No results found"
|
|
description={`Your search for "${searchText}" did not return any results`}
|
|
/>
|
|
)}
|
|
{(entityTypes?.length ?? 0) > 0 && (
|
|
<div className="flex flex-1 min-h-0 w-full" data-testid="tables-list">
|
|
<InfiniteListDefault
|
|
className="h-full w-full"
|
|
items={entityTypes!}
|
|
ItemComponent={EntityListItem}
|
|
LoaderComponent={LoaderForIconMenuItems}
|
|
itemProps={entityProps}
|
|
getItemKey={getItemKey}
|
|
getItemSize={(index) =>
|
|
index !== 0 && index === entityTypes!.length ? 85 : 28
|
|
}
|
|
hasNextPage={hasNextPage}
|
|
isLoadingNextPage={isFetchingNextPage}
|
|
onLoadNextPage={fetchNextPage}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<ExportDialog
|
|
ignoreRoleImpersonation
|
|
table={tableToExport}
|
|
open={!!tableToExport}
|
|
onOpenChange={(open) => {
|
|
if (!open) setTableToExport(undefined)
|
|
}}
|
|
/>
|
|
</>
|
|
)
|
|
}
|