mirror of
https://github.com/supabase/supabase.git
synced 2026-10-14 13:49:09 +03:00
polish
This commit is contained in:
1 parent
237b3ca5b3
commit
b5a183c65e
24 files changed
+999
-362
No files matched your search
+1
-1
@@ -47,7 +47,7 @@ export const TableReplicationRow = ({
|
||||
className="px-1.5"
|
||||
icon={<ExternalLink />}
|
||||
tooltip={{
|
||||
content: { side: 'bottom', text: 'Open in Table Editor' },
|
||||
content: { side: 'bottom', text: 'Table Editor' },
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
|
||||
@@ -162,6 +162,7 @@ export const ColumnList = ({
|
||||
{!isSchemaLocked && isTableEntity && (
|
||||
<ButtonTooltip
|
||||
icon={<Plus />}
|
||||
variant="default"
|
||||
disabled={!canUpdateColumns}
|
||||
onClick={() => onAddColumn()}
|
||||
tooltip={{
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ColumnList } from './ColumnList'
|
||||
import type { SafePostgresColumn } from '@/lib/postgres-types'
|
||||
|
||||
interface TableDetailColumnsTabProps {
|
||||
onAddColumn: () => void
|
||||
onEditColumn: (column: SafePostgresColumn) => void
|
||||
onDeleteColumn: (column: SafePostgresColumn) => void
|
||||
}
|
||||
|
||||
export function TableDetailColumnsTab({
|
||||
onAddColumn,
|
||||
onEditColumn,
|
||||
onDeleteColumn,
|
||||
}: TableDetailColumnsTabProps) {
|
||||
return (
|
||||
<ColumnList
|
||||
onAddColumn={onAddColumn}
|
||||
onEditColumn={onEditColumn}
|
||||
onDeleteColumn={onDeleteColumn}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Card, CardContent } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import {
|
||||
ApiAccessToggle,
|
||||
useTableApiAccessHandlerWithHistory,
|
||||
} from '@/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ApiAccessToggle'
|
||||
import type { TableLike } from '@/data/table-editor/table-editor-types'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
interface TableDetailDataApiSectionProps {
|
||||
table: TableLike
|
||||
}
|
||||
|
||||
export function TableDetailDataApiSection({ table }: TableDetailDataApiSectionProps) {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const handler = useTableApiAccessHandlerWithHistory({
|
||||
type: 'edit',
|
||||
schemaName: table.schema,
|
||||
tableName: table.name,
|
||||
})
|
||||
|
||||
if (handler.isPending) {
|
||||
return <GenericSkeletonLoader />
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<ApiAccessToggle
|
||||
projectRef={project?.ref}
|
||||
schemaName={table.schema}
|
||||
tableName={table.name}
|
||||
isNewRecord={false}
|
||||
handler={handler}
|
||||
hideHeading
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Alert, AlertDescription, AlertTitle, Button, CriticalIcon } from 'ui'
|
||||
|
||||
import type { TableLike } from '@/data/table-editor/table-editor-types'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useTableEditorStateSnapshot } from '@/state/table-editor'
|
||||
|
||||
interface TableDetailDeleteTableSectionProps {
|
||||
table: TableLike
|
||||
}
|
||||
|
||||
export function TableDetailDeleteTableSection({ table }: TableDetailDeleteTableSectionProps) {
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
const { can: canDeleteTables } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'tables'
|
||||
)
|
||||
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CriticalIcon />
|
||||
<AlertTitle>Once this table is deleted, it can no longer be restored</AlertTitle>
|
||||
<AlertDescription>
|
||||
Deleting <code className="text-code-inline">{table.schema}.{table.name}</code> permanently
|
||||
removes the table and its data. Use cascade if dependent objects must be removed too.
|
||||
</AlertDescription>
|
||||
<AlertDescription className="mt-3">
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={!canDeleteTables}
|
||||
onClick={() => snap.onDeleteTable()}
|
||||
>
|
||||
Delete table
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useParams } from 'common'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, CardContent } from 'ui'
|
||||
import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import {
|
||||
TableIndexAdvisorProvider,
|
||||
useTableIndexAdvisor,
|
||||
} from '@/components/grid/context/TableIndexAdvisorContext'
|
||||
import { EnableIndexAdvisorDialog } from '@/components/interfaces/QueryPerformance/IndexAdvisor/EnableIndexAdvisorButton'
|
||||
import { useIndexAdvisorStatus } from '@/components/interfaces/QueryPerformance/hooks/useIsIndexAdvisorStatus'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
import type { TableLike } from '@/data/table-editor/table-editor-types'
|
||||
|
||||
interface TableDetailIndexAdvisorSectionProps {
|
||||
table: TableLike
|
||||
}
|
||||
|
||||
function TableDetailIndexAdvisorContent() {
|
||||
const track = useTrack()
|
||||
const { ref } = useParams()
|
||||
const [enableDialogOpen, setEnableDialogOpen] = useState(false)
|
||||
const { isIndexAdvisorAvailable, isIndexAdvisorEnabled } = useIndexAdvisorStatus()
|
||||
const { columnsWithSuggestions, isLoading } = useTableIndexAdvisor()
|
||||
|
||||
if (!isIndexAdvisorAvailable) return null
|
||||
|
||||
if (!isIndexAdvisorEnabled) {
|
||||
return (
|
||||
<>
|
||||
<FormLayout
|
||||
isReactForm={false}
|
||||
label="Index Advisor"
|
||||
description="Recommends indexes to improve query performance based on your query patterns."
|
||||
layout="flex-row-reverse"
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setEnableDialogOpen(true)
|
||||
track('index_advisor_enable_button_clicked', { origin: 'table_settings' })
|
||||
}}
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
</FormLayout>
|
||||
<EnableIndexAdvisorDialog open={enableDialogOpen} setOpen={setEnableDialogOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <GenericSkeletonLoader />
|
||||
}
|
||||
|
||||
const suggestionCount = columnsWithSuggestions.length
|
||||
|
||||
return (
|
||||
<FormLayout
|
||||
isReactForm={false}
|
||||
label="Index Advisor"
|
||||
description={
|
||||
suggestionCount > 0
|
||||
? `${suggestionCount} column${suggestionCount === 1 ? '' : 's'} with index recommendations based on query patterns.`
|
||||
: 'Enabled. Recommendations will appear as query patterns are analyzed.'
|
||||
}
|
||||
layout="flex-row-reverse"
|
||||
>
|
||||
<Button asChild variant="default">
|
||||
<Link href={`/project/${ref}/observability/query-performance`}>View recommendations</Link>
|
||||
</Button>
|
||||
</FormLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableDetailIndexAdvisorSection({ table }: TableDetailIndexAdvisorSectionProps) {
|
||||
const { isIndexAdvisorAvailable } = useIndexAdvisorStatus()
|
||||
|
||||
if (!isIndexAdvisorAvailable) return null
|
||||
|
||||
return (
|
||||
<TableIndexAdvisorProvider schema={table.schema} table={table.name}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<TableDetailIndexAdvisorContent />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TableIndexAdvisorProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Check, X } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { Entity } from '@/data/table-editor/table-editor-types'
|
||||
import { isTableLike } from '@/data/table-editor/table-editor-types'
|
||||
|
||||
interface TableDetailOverviewProps {
|
||||
entity: Entity
|
||||
realtimeEnabled: boolean
|
||||
}
|
||||
|
||||
function OverviewItem({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-foreground-lighter">{label}</span>
|
||||
<div className="text-sm text-foreground">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableDetailOverview({ entity, realtimeEnabled }: TableDetailOverviewProps) {
|
||||
const isTable = isTableLike(entity)
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-surface-75 px-4 py-3">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<OverviewItem label="Schema">
|
||||
<code className="text-code-inline">{entity.schema}</code>
|
||||
</OverviewItem>
|
||||
|
||||
{isTable && (
|
||||
<OverviewItem label="Row level security">
|
||||
<div className="flex items-center gap-2">
|
||||
{entity.rls_enabled ? (
|
||||
<>
|
||||
<Check size={14} className="text-brand-link" />
|
||||
<span className="text-foreground-light">Enabled</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X size={14} className="text-foreground-muted" />
|
||||
<span className="text-foreground-lighter">Disabled</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</OverviewItem>
|
||||
)}
|
||||
|
||||
{isTable && (
|
||||
<OverviewItem label="Realtime">
|
||||
<div className="flex items-center gap-2">
|
||||
{realtimeEnabled ? (
|
||||
<>
|
||||
<Check size={14} className="text-brand-link" />
|
||||
<span className="text-foreground-light">Enabled</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X size={14} className="text-foreground-muted" />
|
||||
<span className="text-foreground-lighter">Disabled</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</OverviewItem>
|
||||
)}
|
||||
|
||||
{isTable && entity.size !== undefined && (
|
||||
<OverviewItem label="Postgres size">
|
||||
<span className="text-foreground-light">{entity.size}</span>
|
||||
</OverviewItem>
|
||||
)}
|
||||
|
||||
{entity.comment !== undefined && entity.comment !== null && entity.comment !== '' && (
|
||||
<OverviewItem label="Comment">
|
||||
<span className="text-foreground-light">{entity.comment}</span>
|
||||
</OverviewItem>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useParams } from 'common'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
PageSection,
|
||||
PageSectionContent,
|
||||
PageSectionMeta,
|
||||
PageSectionSummary,
|
||||
PageSectionTitle,
|
||||
} from 'ui-patterns/PageSection'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
import { ColumnList } from './ColumnList'
|
||||
import { TableDetailOverview } from './TableDetailOverview'
|
||||
import { warehouseDemoStore } from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
|
||||
import { WarehouseSyncChip } from '@/components/interfaces/Database/Warehouse/WarehouseSyncChip'
|
||||
import { WarehouseTableStoragePanel } from '@/components/interfaces/Database/Warehouse/WarehouseTableStoragePanel'
|
||||
import DeleteConfirmationDialogs from '@/components/interfaces/TableGridEditor/DeleteConfirmationDialogs'
|
||||
import { SidePanelEditor } from '@/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor'
|
||||
import { PageLayout } from '@/components/layouts/PageLayout/PageLayout'
|
||||
import { useDatabasePublicationsQuery } from '@/data/database-publications/database-publications-query'
|
||||
import { useTableEditorQuery } from '@/data/table-editor/table-editor-query'
|
||||
import { isTableLike } from '@/data/table-editor/table-editor-types'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useTableEditorStateSnapshot } from '@/state/table-editor'
|
||||
import { TableEditorTableStateContextProvider } from '@/state/table-editor-table'
|
||||
|
||||
export function TableDetailPageContent() {
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
const { id: _id, ref } = useParams()
|
||||
const id = _id ? Number(_id) : undefined
|
||||
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: selectedTable, isPending: isLoading } = useTableEditorQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
id,
|
||||
})
|
||||
|
||||
const { data: publications } = useDatabasePublicationsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const realtimePublication = (publications ?? []).find(
|
||||
(publication) => publication.name === 'supabase_realtime'
|
||||
)
|
||||
const realtimeEnabled =
|
||||
selectedTable?.id !== undefined &&
|
||||
(realtimePublication?.tables ?? []).some((table) => table.id === selectedTable.id)
|
||||
|
||||
const warehouseSnap = useSnapshot(warehouseDemoStore)
|
||||
const tableKey =
|
||||
selectedTable?.schema !== undefined && selectedTable?.name !== undefined
|
||||
? `${selectedTable.schema}.${selectedTable.name}`
|
||||
: undefined
|
||||
const warehouseState = tableKey ? warehouseSnap.tables[tableKey] : undefined
|
||||
const storageSyncError = warehouseState?.syncState === 'error'
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout
|
||||
title={isLoading ? <ShimmeringLoader className="w-40" /> : (selectedTable?.name ?? '')}
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: 'Tables',
|
||||
href: `/project/${ref}/database/tables`,
|
||||
},
|
||||
]}
|
||||
size="large"
|
||||
>
|
||||
<PageContainer size="large" className="flex flex-col gap-8">
|
||||
{selectedTable !== undefined && !isLoading && (
|
||||
<TableDetailOverview entity={selectedTable} realtimeEnabled={realtimeEnabled} />
|
||||
)}
|
||||
|
||||
{tableKey && selectedTable !== undefined && isTableLike(selectedTable) && (
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<div className="flex items-center gap-2">
|
||||
<PageSectionTitle>Storage</PageSectionTitle>
|
||||
{storageSyncError && <WarehouseSyncChip syncState="error" />}
|
||||
</div>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<WarehouseTableStoragePanel tableKey={tableKey} postgresSize={selectedTable.size} />
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
)}
|
||||
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Columns</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<ColumnList
|
||||
onAddColumn={snap.onAddColumn}
|
||||
onEditColumn={snap.onEditColumn}
|
||||
onDeleteColumn={snap.onDeleteColumn}
|
||||
/>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
</PageContainer>
|
||||
</PageLayout>
|
||||
|
||||
{project?.ref !== undefined && selectedTable !== undefined && isTableLike(selectedTable) && (
|
||||
<TableEditorTableStateContextProvider
|
||||
key={`table-editor-table-${selectedTable.id}`}
|
||||
projectRef={project.ref}
|
||||
table={selectedTable}
|
||||
>
|
||||
<DeleteConfirmationDialogs selectedTable={selectedTable} />
|
||||
<SidePanelEditor includeColumns selectedTable={selectedTable} />
|
||||
</TableEditorTableStateContextProvider>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { Policies } from '@/components/interfaces/Auth/Policies/Policies'
|
||||
import { PoliciesDataProvider } from '@/components/interfaces/Auth/Policies/PoliciesDataContext'
|
||||
import { PolicyEditorPanel } from '@/components/interfaces/Auth/Policies/PolicyEditorPanel'
|
||||
import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils'
|
||||
import { getExposedSchemas } from '@/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
|
||||
import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query'
|
||||
import type { TableLike } from '@/data/table-editor/table-editor-types'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
|
||||
|
||||
interface TableDetailPoliciesTabProps {
|
||||
table: TableLike
|
||||
}
|
||||
|
||||
export function TableDetailPoliciesTab({ table }: TableDetailPoliciesTabProps) {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const isSchemaLocked = useIsProtectedSchema({ schema: table.schema }).isSchemaLocked
|
||||
|
||||
const [showPolicyEditor, setShowPolicyEditor] = useState(false)
|
||||
const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState<Policy>()
|
||||
|
||||
const { data: dbSchema } = useProjectPostgrestConfigQuery(
|
||||
{ projectRef: project?.ref },
|
||||
{ select: ({ db_schema }) => db_schema }
|
||||
)
|
||||
const exposedSchemas = useMemo(() => getExposedSchemas(dbSchema), [dbSchema])
|
||||
|
||||
const {
|
||||
data: policies,
|
||||
isPending: isLoadingPolicies,
|
||||
isError: isPoliciesError,
|
||||
error: policiesError,
|
||||
} = useDatabasePoliciesQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const visibleTableIds = useMemo(() => new Set([table.id]), [table.id])
|
||||
|
||||
const policyTable = useMemo(
|
||||
() => ({
|
||||
id: table.id,
|
||||
schema: table.schema,
|
||||
name: table.name,
|
||||
rls_enabled: table.rls_enabled,
|
||||
}),
|
||||
[table]
|
||||
)
|
||||
|
||||
if (isLoadingPolicies) {
|
||||
return <GenericSkeletonLoader />
|
||||
}
|
||||
|
||||
if (isPoliciesError) {
|
||||
return <AlertError error={policiesError} subject="Failed to retrieve policies" />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PoliciesDataProvider
|
||||
policies={policies ?? []}
|
||||
isPoliciesLoading={isLoadingPolicies}
|
||||
isPoliciesError={isPoliciesError}
|
||||
policiesError={policiesError ?? undefined}
|
||||
exposedSchemas={exposedSchemas}
|
||||
>
|
||||
<Policies
|
||||
schema={table.schema}
|
||||
tables={[policyTable]}
|
||||
hasTables
|
||||
isLocked={isSchemaLocked}
|
||||
visibleTableIds={visibleTableIds}
|
||||
onSelectCreatePolicy={() => {
|
||||
setSelectedPolicyToEdit(undefined)
|
||||
setShowPolicyEditor(true)
|
||||
}}
|
||||
onSelectEditPolicy={(policy) => {
|
||||
setSelectedPolicyToEdit(policy)
|
||||
setShowPolicyEditor(true)
|
||||
}}
|
||||
/>
|
||||
</PoliciesDataProvider>
|
||||
|
||||
<PolicyEditorPanel
|
||||
visible={showPolicyEditor}
|
||||
searchString={String(table.id)}
|
||||
schema={table.schema}
|
||||
selectedPolicy={selectedPolicyToEdit}
|
||||
onSelectCancel={() => setShowPolicyEditor(false)}
|
||||
authContext="database"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Realtime } from 'icons'
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, CardContent } from 'ui'
|
||||
import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
|
||||
import {
|
||||
PageSection,
|
||||
PageSectionContent,
|
||||
PageSectionMeta,
|
||||
PageSectionSummary,
|
||||
PageSectionTitle,
|
||||
} from 'ui-patterns/PageSection'
|
||||
|
||||
import { TableDetailDataApiSection } from '@/components/interfaces/Database/Tables/TableDetailDataApiSection'
|
||||
import { TableDetailDeleteTableSection } from '@/components/interfaces/Database/Tables/TableDetailDeleteTableSection'
|
||||
import { TableDetailIndexAdvisorSection } from '@/components/interfaces/Database/Tables/TableDetailIndexAdvisorSection'
|
||||
import { WarehouseTableStoragePanel } from '@/components/interfaces/Database/Warehouse/WarehouseTableStoragePanel'
|
||||
import { RealtimeToggleDialog } from '@/components/interfaces/TableGridEditor/RealtimeToggleDialog'
|
||||
import { useIsTableRealtimeEnabled } from '@/data/database-publications/database-publications-query'
|
||||
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
|
||||
import type { TableLike } from '@/data/table-editor/table-editor-types'
|
||||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
|
||||
interface TableDetailSettingsTabProps {
|
||||
table: TableLike
|
||||
}
|
||||
|
||||
export function TableDetailSettingsTab({ table }: TableDetailSettingsTabProps) {
|
||||
const [realtimeDialogOpen, setRealtimeDialogOpen] = useState(false)
|
||||
const { realtimeAll: isRealtimeFeatureEnabled } = useIsFeatureEnabled(['realtime:all'])
|
||||
const isRealtimeEnabled = useIsTableRealtimeEnabled({ id: table.id })
|
||||
const showStorage = table.entity_type === ENTITY_TYPE.TABLE
|
||||
const tableKey = `${table.schema}.${table.name}`
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-8">
|
||||
{showStorage && (
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Storage</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<WarehouseTableStoragePanel tableKey={tableKey} postgresSize={table.size} />
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
)}
|
||||
|
||||
{isRealtimeFeatureEnabled && (
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Realtime</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormLayout
|
||||
isReactForm={false}
|
||||
label="Realtime"
|
||||
description="Broadcast changes on this table to authorized subscribers."
|
||||
layout="flex-row-reverse"
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
icon={<Realtime size={14} className={isRealtimeEnabled ? 'text-brand' : ''} />}
|
||||
onClick={() => setRealtimeDialogOpen(true)}
|
||||
>
|
||||
{isRealtimeEnabled ? 'Disable' : 'Enable'} realtime
|
||||
</Button>
|
||||
</FormLayout>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
)}
|
||||
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Index Advisor</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<TableDetailIndexAdvisorSection table={table} />
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Data API access</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<TableDetailDataApiSection table={table} />
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Delete table</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<TableDetailDeleteTableSection table={table} />
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
</div>
|
||||
|
||||
<RealtimeToggleDialog
|
||||
table={table}
|
||||
open={realtimeDialogOpen}
|
||||
setOpen={setRealtimeDialogOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useParams } from 'common'
|
||||
import { noop } from 'lodash'
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Columns2,
|
||||
Copy,
|
||||
Edit,
|
||||
@@ -50,7 +51,7 @@ import { useSnapshot } from 'valtio'
|
||||
|
||||
import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning'
|
||||
import {
|
||||
formatWarehouseSize,
|
||||
getWarehouseStorageSummaryLabel,
|
||||
warehouseDemoStore,
|
||||
type WarehouseMode,
|
||||
} from '../Warehouse/warehouseDemoStore'
|
||||
@@ -560,7 +561,7 @@ export const TableList = ({
|
||||
return (
|
||||
<TableRow
|
||||
key={x.id}
|
||||
className="group cursor-pointer hover:bg-surface-100 inset-focus"
|
||||
className="group relative cursor-pointer inset-focus"
|
||||
onClick={handleRowNavigation}
|
||||
onAuxClick={handleRowNavigation}
|
||||
onKeyDown={handleRowNavigation}
|
||||
@@ -625,7 +626,11 @@ export const TableList = ({
|
||||
mode: 'postgres',
|
||||
}
|
||||
const mode = wState.mode as WarehouseMode
|
||||
const warehouseSize = formatWarehouseSize(wState.warehouseSizeBytes)
|
||||
const storageSummary = getWarehouseStorageSummaryLabel(
|
||||
wState,
|
||||
x.size
|
||||
)
|
||||
const storageUrl = `${tableDetailUrl}/settings`
|
||||
const showSyncChip =
|
||||
wState.syncState === 'syncing' || wState.syncState === 'error'
|
||||
|
||||
@@ -637,14 +642,12 @@ export const TableList = ({
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={tableDetailUrl}
|
||||
href={storageUrl}
|
||||
onClick={(event: MouseEvent) => event.stopPropagation()}
|
||||
className="inline-flex max-w-full items-center gap-2 rounded-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground-lighter"
|
||||
>
|
||||
<span className="text-sm text-foreground-light">
|
||||
{mode === 'warehouse_backed'
|
||||
? `${warehouseSize} · Moved`
|
||||
: `${x.size ?? '—'} · Copy · ${warehouseSize}`}
|
||||
{storageSummary}
|
||||
</span>
|
||||
{showSyncChip && (
|
||||
<WarehouseSyncChip syncState={wState.syncState!} />
|
||||
@@ -673,8 +676,9 @@ export const TableList = ({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
className="flex justify-end gap-2"
|
||||
className="flex items-center justify-end gap-3"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -730,7 +734,7 @@ export const TableList = ({
|
||||
}}
|
||||
>
|
||||
<Edit size={12} />
|
||||
<p>Edit table</p>
|
||||
<p>Edit definitions</p>
|
||||
</DropdownMenuItemTooltip>
|
||||
<DropdownMenuItemTooltip
|
||||
key="duplicate-table"
|
||||
@@ -773,6 +777,11 @@ export const TableList = ({
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
size={14}
|
||||
className="text-foreground-muted/60"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
+28
-13
@@ -95,19 +95,10 @@ export function WarehouseTableStoragePanel({
|
||||
{mode === 'has_warehouse_copy' && (
|
||||
<>
|
||||
{state.syncState && (
|
||||
<MetaRow label="Status">
|
||||
<MetaRow label="Sync status">
|
||||
<WarehouseSyncChip syncState={state.syncState} />
|
||||
</MetaRow>
|
||||
)}
|
||||
{state.lastSyncedAt !== undefined && (
|
||||
<MetaRow label="Last synced">
|
||||
<TimestampInfo
|
||||
className="text-sm text-foreground-light"
|
||||
utcTimestamp={state.lastSyncedAt}
|
||||
displayAs="local"
|
||||
/>
|
||||
</MetaRow>
|
||||
)}
|
||||
{state.lagSeconds !== undefined && (
|
||||
<MetaRow label="Lag">
|
||||
<span className="text-foreground-light">{state.lagSeconds}s</span>
|
||||
@@ -165,15 +156,39 @@ export function WarehouseTableStoragePanel({
|
||||
)}
|
||||
|
||||
{mode === 'has_warehouse_copy' && (
|
||||
<Button type="button" variant="default" onClick={() => setDetachConfirm(true)}>
|
||||
Detach Warehouse copy
|
||||
</Button>
|
||||
<div className="flex">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
className="rounded-r-none"
|
||||
onClick={() => setEnablementModal('move')}
|
||||
>
|
||||
Move fully to Warehouse
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
icon={<ChevronDown />}
|
||||
className="rounded-l-none border-l-0 px-2"
|
||||
aria-label="More storage actions"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setDetachConfirm(true)}>
|
||||
Detach Warehouse copy
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'warehouse_backed' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
className="w-fit"
|
||||
icon={<History />}
|
||||
onClick={() => setTimeTravelOpen(true)}
|
||||
>
|
||||
|
||||
@@ -86,6 +86,20 @@ export function formatWarehouseSize(bytes: number | undefined): string {
|
||||
return `${Math.round(gb)} GB`
|
||||
}
|
||||
|
||||
export function getWarehouseStorageSummaryLabel(
|
||||
state: Pick<WarehouseTableState, 'mode' | 'warehouseSizeBytes'> | undefined,
|
||||
postgresSize?: string
|
||||
): string | null {
|
||||
const mode = state?.mode ?? 'postgres'
|
||||
const warehouseSize = formatWarehouseSize(state?.warehouseSizeBytes)
|
||||
|
||||
if (mode === 'postgres') return null
|
||||
if (mode === 'has_warehouse_copy') {
|
||||
return postgresSize ? `${postgresSize} · Copy · ${warehouseSize}` : `Copy · ${warehouseSize}`
|
||||
}
|
||||
return `${warehouseSize} · Moved`
|
||||
}
|
||||
|
||||
export function getWarehouseSnapshots(tableKey: string): WarehouseSnapshot[] {
|
||||
const state = warehouseDemoStore.tables[tableKey]
|
||||
if (state?.mode !== 'warehouse_backed') return []
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ export const IndexAdvisorNotice = () => {
|
||||
if (!isIndexAdvisorAvailable || isIndexAdvisorEnabled || isDismissed) return null
|
||||
|
||||
return (
|
||||
<div className="px-6">
|
||||
<div>
|
||||
<Admonition showIcon={false} type="tip" className="relative overflow-hidden mb-4">
|
||||
<div className="absolute -inset-16 z-0 opacity-50">
|
||||
<img
|
||||
|
||||
+4
-11
@@ -210,11 +210,12 @@ export const SidePanelEditor = ({
|
||||
snap,
|
||||
selectedTable,
|
||||
})
|
||||
const isTableDefinitionEdit = tableApiAccessParams?.type === 'edit'
|
||||
const apiAccessToggleHandler = useTableApiAccessHandlerWithHistory(
|
||||
// Dummy params used to appease TypeScript, actually gated by enabled flag
|
||||
tableApiAccessParams ?? DUMMY_TABLE_API_ACCESS_PARAMS,
|
||||
{
|
||||
enabled: tableApiAccessParams !== undefined,
|
||||
enabled: tableApiAccessParams !== undefined && !isTableDefinitionEdit,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -600,7 +601,7 @@ export const SidePanelEditor = ({
|
||||
let toastId
|
||||
let saveTableError = false
|
||||
|
||||
if (!apiAccessToggleHandler.isSuccess) {
|
||||
if (action !== 'update' && !apiAccessToggleHandler.isSuccess) {
|
||||
if (apiAccessToggleHandler.isPending) {
|
||||
toast.info(
|
||||
'Cannot save table yet because Data API settings are still loading. Please try again in a moment.'
|
||||
@@ -819,15 +820,7 @@ export const SidePanelEditor = ({
|
||||
if (table === undefined) {
|
||||
return toast.error('Failed to update table')
|
||||
}
|
||||
if (isTableLike(table)) {
|
||||
await updateTableRealtime(table, isRealtimeEnabled)
|
||||
const privilegesToSet = apiAccessToggleHandler.data?.schemaExposed
|
||||
? apiAccessToggleHandler.data.privileges
|
||||
: undefined
|
||||
if (privilegesToSet) {
|
||||
await updateTableApiAccess(table, privilegesToSet)
|
||||
}
|
||||
}
|
||||
// Realtime and Data API access are managed on the table details Settings tab.
|
||||
|
||||
if (hasError) {
|
||||
toast.warning(
|
||||
|
||||
+3
-1
@@ -278,6 +278,7 @@ type ApiAccessToggleComponentProps = {
|
||||
tableName?: string
|
||||
isNewRecord: boolean
|
||||
handler: TableApiAccessHandlerWithHistoryReturn
|
||||
hideHeading?: boolean
|
||||
}
|
||||
|
||||
export const ApiAccessToggle = ({
|
||||
@@ -286,6 +287,7 @@ export const ApiAccessToggle = ({
|
||||
tableName,
|
||||
isNewRecord,
|
||||
handler,
|
||||
hideHeading = false,
|
||||
}: ApiAccessToggleComponentProps): ReactNode => {
|
||||
const track = useTrack()
|
||||
const isPending = handler.isPending
|
||||
@@ -320,7 +322,7 @@ export const ApiAccessToggle = ({
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h5>Data API access</h5>
|
||||
{!hideHeading && <h5>Data API access</h5>}
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Allow this table to be queried via Supabase client libraries or the API directly
|
||||
</p>
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ export const HeaderTitle = ({ schema, table, isDuplicating }: HeaderTitleProps)
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
Update table <code className="text-code-inline text-sm!">{table?.name}</code>
|
||||
Edit definitions for <code className="text-code-inline text-sm!">{table?.name}</code>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
+119
-112
@@ -84,6 +84,7 @@ export const TableEditor = ({
|
||||
const { selectedSchema } = useQuerySchemaState()
|
||||
|
||||
const isNewRecord = table === undefined
|
||||
const isDefinitionEdit = !isNewRecord && !isDuplicating
|
||||
const visibleChanged = useChanged(visible)
|
||||
|
||||
const [errors, setErrors] = useState<PlainObject>({})
|
||||
@@ -213,14 +214,13 @@ export const TableEditor = ({
|
||||
|
||||
const isNameChanged = tableFields.name.trim() !== table?.name
|
||||
const isCommentChanged = tableFields.comment !== table?.comment
|
||||
const isRlsEnabledChanged = tableFields.isRLSEnabled !== (table?.rls_enabled ?? false)
|
||||
|
||||
if (isEmpty(errors)) {
|
||||
const configuration = {
|
||||
tableId: table?.id,
|
||||
importContent,
|
||||
isRLSEnabled: tableFields.isRLSEnabled,
|
||||
isRealtimeEnabled: tableFields.isRealtimeEnabled,
|
||||
isRLSEnabled: isDefinitionEdit ? (table?.rls_enabled ?? false) : tableFields.isRLSEnabled,
|
||||
isRealtimeEnabled: isDefinitionEdit ? isRealtimeEnabled : tableFields.isRealtimeEnabled,
|
||||
isDuplicateRows: isDuplicateRows,
|
||||
existingForeignKeyRelations: foreignKeys,
|
||||
primaryKey,
|
||||
@@ -262,7 +262,6 @@ export const TableEditor = ({
|
||||
const payload: SaveTablePayloadFor<'update'> = {
|
||||
...(isNameChanged && { name: tableFields.name.trim() }),
|
||||
...(isCommentChanged && { comment: tableFields.comment?.trim() ?? '' }),
|
||||
...(isRlsEnabledChanged && { rls_enabled: tableFields.isRLSEnabled }),
|
||||
}
|
||||
saveChanges({
|
||||
action: 'update',
|
||||
@@ -434,104 +433,108 @@ export const TableEditor = ({
|
||||
</FormItemLayout>
|
||||
</SidePanel.Content>
|
||||
|
||||
<SidePanel.Separator />
|
||||
{!isDefinitionEdit && (
|
||||
<>
|
||||
<SidePanel.Separator />
|
||||
|
||||
<SidePanel.Content className="space-y-10 py-6">
|
||||
<div className="items-top flex space-x-2">
|
||||
<Checkbox
|
||||
id="enable-rls"
|
||||
checked={tableFields.isRLSEnabled}
|
||||
onCheckedChange={() => {
|
||||
// if isEnabled, show confirm modal to turn off
|
||||
// if not enabled, allow turning on without modal confirmation
|
||||
tableFields.isRLSEnabled
|
||||
? setRlsConfirmVisible(true)
|
||||
: onUpdateField({ isRLSEnabled: !tableFields.isRLSEnabled })
|
||||
}}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="enable-rls"
|
||||
className="text-sm text-foreground-light flex items-center space-x-2 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
<span>Enable Row Level Security (RLS)</span>
|
||||
<Badge>Recommended</Badge>
|
||||
</label>
|
||||
<p className="text-sm text-foreground-muted">
|
||||
Restrict access to your table by enabling RLS and writing Postgres policies.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tableFields.isRLSEnabled ? (
|
||||
<Admonition
|
||||
type="default"
|
||||
className="mt-3!"
|
||||
title="Policies are required to query data"
|
||||
description={
|
||||
<>
|
||||
You need to create an access policy before you can query data from this table.
|
||||
Without a policy, querying this table will return an{' '}
|
||||
<u className="text-foreground">empty array</u> of results.{' '}
|
||||
{isNewRecord ? 'You can create policies after saving this table.' : ''}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DocsButton
|
||||
abbrev={false}
|
||||
className="mt-2 w-min"
|
||||
href={`${DOCS_URL}${docsRowLevelSecurityGuidePath}`}
|
||||
/>
|
||||
</Admonition>
|
||||
) : (
|
||||
<Admonition
|
||||
type="warning"
|
||||
className="mt-3!"
|
||||
title="You are allowing anonymous access to your table"
|
||||
description={
|
||||
<>
|
||||
{tableFields.name ? `The table ${tableFields.name}` : 'Your table'} will be
|
||||
publicly writable and readable
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DocsButton
|
||||
abbrev={false}
|
||||
className="mt-2 w-min"
|
||||
href={`${DOCS_URL}${docsRowLevelSecurityGuidePath}`}
|
||||
/>
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
{realtimeEnabled && (
|
||||
<div className="items-top flex space-x-2">
|
||||
<Checkbox
|
||||
id="enable-realtime"
|
||||
checked={tableFields.isRealtimeEnabled}
|
||||
onCheckedChange={() => {
|
||||
track('realtime_toggle_table_clicked', {
|
||||
newState: tableFields.isRealtimeEnabled ? 'disabled' : 'enabled',
|
||||
origin: 'tableSidePanel',
|
||||
})
|
||||
onUpdateField({
|
||||
isRealtimeEnabled: !tableFields.isRealtimeEnabled,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="enable-realtime"
|
||||
className="text-sm text-foreground-light flex items-center space-x-2 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Enable Realtime
|
||||
</label>
|
||||
<p className="text-sm text-foreground-muted">
|
||||
Broadcast changes on this table to authorized subscribers.
|
||||
</p>
|
||||
<SidePanel.Content className="space-y-10 py-6">
|
||||
<div className="items-top flex space-x-2">
|
||||
<Checkbox
|
||||
id="enable-rls"
|
||||
checked={tableFields.isRLSEnabled}
|
||||
onCheckedChange={() => {
|
||||
// if isEnabled, show confirm modal to turn off
|
||||
// if not enabled, allow turning on without modal confirmation
|
||||
tableFields.isRLSEnabled
|
||||
? setRlsConfirmVisible(true)
|
||||
: onUpdateField({ isRLSEnabled: !tableFields.isRLSEnabled })
|
||||
}}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="enable-rls"
|
||||
className="text-sm text-foreground-light flex items-center space-x-2 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
<span>Enable Row Level Security (RLS)</span>
|
||||
<Badge>Recommended</Badge>
|
||||
</label>
|
||||
<p className="text-sm text-foreground-muted">
|
||||
Restrict access to your table by enabling RLS and writing Postgres policies.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SidePanel.Content>
|
||||
|
||||
{tableFields.isRLSEnabled ? (
|
||||
<Admonition
|
||||
type="default"
|
||||
className="mt-3!"
|
||||
title="Policies are required to query data"
|
||||
description={
|
||||
<>
|
||||
You need to create an access policy before you can query data from this table.
|
||||
Without a policy, querying this table will return an{' '}
|
||||
<u className="text-foreground">empty array</u> of results.{' '}
|
||||
{isNewRecord ? 'You can create policies after saving this table.' : ''}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DocsButton
|
||||
abbrev={false}
|
||||
className="mt-2 w-min"
|
||||
href={`${DOCS_URL}${docsRowLevelSecurityGuidePath}`}
|
||||
/>
|
||||
</Admonition>
|
||||
) : (
|
||||
<Admonition
|
||||
type="warning"
|
||||
className="mt-3!"
|
||||
title="You are allowing anonymous access to your table"
|
||||
description={
|
||||
<>
|
||||
{tableFields.name ? `The table ${tableFields.name}` : 'Your table'} will be
|
||||
publicly writable and readable
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DocsButton
|
||||
abbrev={false}
|
||||
className="mt-2 w-min"
|
||||
href={`${DOCS_URL}${docsRowLevelSecurityGuidePath}`}
|
||||
/>
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
{realtimeEnabled && (
|
||||
<div className="items-top flex space-x-2">
|
||||
<Checkbox
|
||||
id="enable-realtime"
|
||||
checked={tableFields.isRealtimeEnabled}
|
||||
onCheckedChange={() => {
|
||||
track('realtime_toggle_table_clicked', {
|
||||
newState: tableFields.isRealtimeEnabled ? 'disabled' : 'enabled',
|
||||
origin: 'tableSidePanel',
|
||||
})
|
||||
onUpdateField({
|
||||
isRealtimeEnabled: !tableFields.isRealtimeEnabled,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="enable-realtime"
|
||||
className="text-sm text-foreground-light flex items-center space-x-2 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Enable Realtime
|
||||
</label>
|
||||
<p className="text-sm text-foreground-muted">
|
||||
Broadcast changes on this table to authorized subscribers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SidePanel.Content>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SidePanel.Separator />
|
||||
|
||||
@@ -613,18 +616,22 @@ export const TableEditor = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
<SidePanel.Separator />
|
||||
<SidePanel.Content className="py-6 space-y-6">
|
||||
<ApiAccessToggle
|
||||
projectRef={project?.ref}
|
||||
schemaName={isNewRecord ? selectedSchema : table?.schema}
|
||||
tableName={
|
||||
isNewRecord || isDuplicating ? tableFields.name : tableFields.name || table?.name
|
||||
}
|
||||
isNewRecord={isNewRecord || isDuplicating}
|
||||
handler={apiAccessToggleHandler}
|
||||
/>
|
||||
</SidePanel.Content>
|
||||
{!isDefinitionEdit && (
|
||||
<>
|
||||
<SidePanel.Separator />
|
||||
<SidePanel.Content className="py-6 space-y-6">
|
||||
<ApiAccessToggle
|
||||
projectRef={project?.ref}
|
||||
schemaName={isNewRecord ? selectedSchema : table?.schema}
|
||||
tableName={
|
||||
isNewRecord || isDuplicating ? tableFields.name : tableFields.name || table?.name
|
||||
}
|
||||
isNewRecord={isNewRecord || isDuplicating}
|
||||
handler={apiAccessToggleHandler}
|
||||
/>
|
||||
</SidePanel.Content>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</SidePanel>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { TableEditor } from 'icons'
|
||||
import { Check, Edit, X } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import React, { type PropsWithChildren } from 'react'
|
||||
import {
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
Button,
|
||||
NavMenu,
|
||||
NavMenuItem,
|
||||
} from 'ui'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
PageHeader,
|
||||
PageHeaderAside,
|
||||
PageHeaderBreadcrumb,
|
||||
PageHeaderDescription,
|
||||
PageHeaderMeta,
|
||||
PageHeaderNavigationTabs,
|
||||
PageHeaderSummary,
|
||||
PageHeaderTitle,
|
||||
} from 'ui-patterns/PageHeader'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
import DatabaseLayout from './DatabaseLayout'
|
||||
import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils'
|
||||
import {
|
||||
getWarehouseStorageSummaryLabel,
|
||||
warehouseDemoStore,
|
||||
} from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
|
||||
import { WarehouseSyncChip } from '@/components/interfaces/Database/Warehouse/WarehouseSyncChip'
|
||||
import DeleteConfirmationDialogs from '@/components/interfaces/TableGridEditor/DeleteConfirmationDialogs'
|
||||
import { SidePanelEditor } from '@/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor'
|
||||
import { EntityTypeIcon } from '@/components/ui/EntityTypeIcon'
|
||||
import { useDatabasePublicationsQuery } from '@/data/database-publications/database-publications-query'
|
||||
import { useTableEditorQuery } from '@/data/table-editor/table-editor-query'
|
||||
import { isTableLike } from '@/data/table-editor/table-editor-types'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useTableEditorStateSnapshot } from '@/state/table-editor'
|
||||
import { TableEditorTableStateContextProvider } from '@/state/table-editor-table'
|
||||
|
||||
export type TableDetailSection = 'columns' | 'policies' | 'settings'
|
||||
|
||||
interface TableDetailLayoutProps {
|
||||
section: TableDetailSection
|
||||
}
|
||||
|
||||
export function TableDetailLayout({
|
||||
section: _section,
|
||||
children,
|
||||
}: PropsWithChildren<TableDetailLayoutProps>) {
|
||||
const router = useRouter()
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
const { id: _id, ref } = useParams()
|
||||
const id = _id ? Number(_id) : undefined
|
||||
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: selectedTable, isPending: isLoading } = useTableEditorQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
id,
|
||||
})
|
||||
|
||||
const { data: publications } = useDatabasePublicationsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const realtimePublication = (publications ?? []).find(
|
||||
(publication) => publication.name === 'supabase_realtime'
|
||||
)
|
||||
const realtimeEnabled =
|
||||
selectedTable?.id !== undefined &&
|
||||
(realtimePublication?.tables ?? []).some((table) => table.id === selectedTable.id)
|
||||
|
||||
const warehouseSnap = useSnapshot(warehouseDemoStore)
|
||||
const tableKey =
|
||||
selectedTable?.schema !== undefined && selectedTable?.name !== undefined
|
||||
? `${selectedTable.schema}.${selectedTable.name}`
|
||||
: undefined
|
||||
const warehouseState = tableKey ? warehouseSnap.tables[tableKey] : undefined
|
||||
|
||||
const { can: canUpdateTables } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'tables'
|
||||
)
|
||||
|
||||
const isTable = selectedTable !== undefined && isTableLike(selectedTable)
|
||||
const showPoliciesTab = isTable
|
||||
const showSettingsTab = isTable
|
||||
const storageSyncError = warehouseState?.syncState === 'error'
|
||||
const tableSizeLabel =
|
||||
isTable && selectedTable.size !== undefined
|
||||
? (getWarehouseStorageSummaryLabel(warehouseState, selectedTable.size) ?? selectedTable.size)
|
||||
: isTable
|
||||
? getWarehouseStorageSummaryLabel(warehouseState, selectedTable.size)
|
||||
: null
|
||||
|
||||
const tableEditorUrl =
|
||||
selectedTable !== undefined
|
||||
? buildTableEditorUrl({
|
||||
projectRef: ref,
|
||||
tableId: selectedTable.id,
|
||||
schema: selectedTable.schema,
|
||||
})
|
||||
: undefined
|
||||
|
||||
const navigationItems = id
|
||||
? [
|
||||
{
|
||||
label: 'Columns',
|
||||
href: `/project/${ref}/database/tables/${id}`,
|
||||
},
|
||||
...(showPoliciesTab
|
||||
? [
|
||||
{
|
||||
label: 'Policies',
|
||||
href: `/project/${ref}/database/tables/${id}/policies`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(showSettingsTab
|
||||
? [
|
||||
{
|
||||
label: 'Settings',
|
||||
href: `/project/${ref}/database/tables/${id}/settings`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<DatabaseLayout title="Tables">
|
||||
<div className="flex min-h-full w-full flex-col items-stretch">
|
||||
<PageHeader size="full" className="sticky top-0 z-10 bg-surface-75">
|
||||
<PageHeaderBreadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={`/project/${ref}/database/tables`}>Tables</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{selectedTable && (
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<span>{selectedTable.name}</span>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</PageHeaderBreadcrumb>
|
||||
|
||||
<PageHeaderMeta className="mb-4">
|
||||
<PageHeaderSummary>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedTable && <EntityTypeIcon type={selectedTable.entity_type} />}
|
||||
<PageHeaderTitle>
|
||||
{isLoading ? <ShimmeringLoader className="w-40" /> : (selectedTable?.name ?? '')}
|
||||
</PageHeaderTitle>
|
||||
{storageSyncError && <WarehouseSyncChip syncState="error" />}
|
||||
</div>
|
||||
|
||||
{selectedTable && !isLoading && (
|
||||
<PageHeaderDescription className="flex flex-col gap-1 text-sm!">
|
||||
{selectedTable.comment && (
|
||||
<p className="text-foreground-lighter mb-1">{selectedTable.comment}</p>
|
||||
)}
|
||||
<div className="flex flex-row flex-wrap items-center gap-x-4 gap-y-1 text-foreground-light">
|
||||
<code className="text-code-inline">{selectedTable.schema}</code>
|
||||
{isTable && selectedTable.live_rows_estimate !== undefined && (
|
||||
<span>{selectedTable.live_rows_estimate.toLocaleString()} rows</span>
|
||||
)}
|
||||
{tableSizeLabel && <span>{tableSizeLabel}</span>}
|
||||
{isTable && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
RLS
|
||||
{selectedTable.rls_enabled ? (
|
||||
<Check size={14} className="text-brand-link" />
|
||||
) : (
|
||||
<X size={14} className="text-foreground-muted" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{isTable && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
Realtime
|
||||
{realtimeEnabled ? (
|
||||
<Check size={14} className="text-brand-link" />
|
||||
) : (
|
||||
<X size={14} className="text-foreground-muted" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</PageHeaderDescription>
|
||||
)}
|
||||
</PageHeaderSummary>
|
||||
|
||||
{isTable && tableEditorUrl && (
|
||||
<PageHeaderAside>
|
||||
<div className="flex items-center gap-2">
|
||||
{canUpdateTables && (
|
||||
<Button variant="default" icon={<Edit />} onClick={() => snap.onEditTable()}>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
icon={<TableEditor size={16} strokeWidth={1.5} />}
|
||||
asChild
|
||||
>
|
||||
<Link href={tableEditorUrl}>View</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</PageHeaderAside>
|
||||
)}
|
||||
</PageHeaderMeta>
|
||||
|
||||
{navigationItems.length > 1 && (
|
||||
<PageHeaderNavigationTabs>
|
||||
<NavMenu>
|
||||
{navigationItems.map((item) => {
|
||||
const isActive = router.asPath.split('?')[0] === item.href
|
||||
return (
|
||||
<NavMenuItem key={item.label} active={isActive}>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</NavMenuItem>
|
||||
)
|
||||
})}
|
||||
</NavMenu>
|
||||
</PageHeaderNavigationTabs>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<PageContainer size={_section === 'settings' ? 'small' : 'large'} className="py-8">
|
||||
{children}
|
||||
</PageContainer>
|
||||
</div>
|
||||
|
||||
{project?.ref !== undefined && selectedTable !== undefined && isTable && (
|
||||
<TableEditorTableStateContextProvider
|
||||
key={`table-editor-table-${selectedTable.id}`}
|
||||
projectRef={project.ref}
|
||||
table={selectedTable}
|
||||
>
|
||||
<DeleteConfirmationDialogs
|
||||
selectedTable={selectedTable}
|
||||
onTableDeleted={() => {
|
||||
router.push(`/project/${ref}/database/tables`)
|
||||
}}
|
||||
/>
|
||||
<SidePanelEditor includeColumns selectedTable={selectedTable} />
|
||||
</TableEditorTableStateContextProvider>
|
||||
)}
|
||||
</DatabaseLayout>
|
||||
)
|
||||
}
|
||||
@@ -356,7 +356,7 @@ export const EntityListItem = ({
|
||||
}}
|
||||
>
|
||||
<Edit size={12} className="shrink-0" />
|
||||
<span>Edit table</span>
|
||||
<span>Edit definitions</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
key="duplicate-table"
|
||||
|
||||
@@ -1,16 +1,58 @@
|
||||
import { TableDetailPageContent } from '@/components/interfaces/Database/Tables/TableDetailPageContent'
|
||||
import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout'
|
||||
import { useParams } from 'common'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { ColumnList } from '@/components/interfaces/Database/Tables/ColumnList'
|
||||
import { TableDetailColumnsTab } from '@/components/interfaces/Database/Tables/TableDetailColumnsTab'
|
||||
import { TableDetailLayout } from '@/components/layouts/DatabaseLayout/TableDetailLayout'
|
||||
import DefaultLayout from '@/components/layouts/DefaultLayout'
|
||||
import { useTableEditorQuery } from '@/data/table-editor/table-editor-query'
|
||||
import { isTableLike } from '@/data/table-editor/table-editor-types'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useTableEditorStateSnapshot } from '@/state/table-editor'
|
||||
import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
const DatabaseTableDetailPage: NextPageWithLayout = () => {
|
||||
return <TableDetailPageContent />
|
||||
const TableDetailColumnsPage: NextPageWithLayout = () => {
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
const { id: _id } = useParams()
|
||||
const id = _id ? Number(_id) : undefined
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: selectedTable, isPending } = useTableEditorQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
id,
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return <ShimmeringLoader />
|
||||
}
|
||||
|
||||
if (selectedTable === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isTableLike(selectedTable)) {
|
||||
return (
|
||||
<TableDetailColumnsTab
|
||||
onAddColumn={snap.onAddColumn}
|
||||
onEditColumn={snap.onEditColumn}
|
||||
onDeleteColumn={snap.onDeleteColumn}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ColumnList
|
||||
onAddColumn={snap.onAddColumn}
|
||||
onEditColumn={snap.onEditColumn}
|
||||
onDeleteColumn={snap.onDeleteColumn}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
DatabaseTableDetailPage.getLayout = (page) => (
|
||||
TableDetailColumnsPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<DatabaseLayout title="Tables">{page}</DatabaseLayout>
|
||||
<TableDetailLayout section="columns">{page}</TableDetailLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default DatabaseTableDetailPage
|
||||
export default TableDetailColumnsPage
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useParams } from 'common'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { TableDetailPoliciesTab } from '@/components/interfaces/Database/Tables/TableDetailPoliciesTab'
|
||||
import { TableDetailLayout } from '@/components/layouts/DatabaseLayout/TableDetailLayout'
|
||||
import DefaultLayout from '@/components/layouts/DefaultLayout'
|
||||
import { useTableEditorQuery } from '@/data/table-editor/table-editor-query'
|
||||
import { isTableLike } from '@/data/table-editor/table-editor-types'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
const TableDetailPoliciesPage: NextPageWithLayout = () => {
|
||||
const { id: _id } = useParams()
|
||||
const id = _id ? Number(_id) : undefined
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: selectedTable, isPending } = useTableEditorQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
id,
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return <ShimmeringLoader />
|
||||
}
|
||||
|
||||
if (selectedTable === undefined || !isTableLike(selectedTable)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <TableDetailPoliciesTab table={selectedTable} />
|
||||
}
|
||||
|
||||
TableDetailPoliciesPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<TableDetailLayout section="policies">{page}</TableDetailLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default TableDetailPoliciesPage
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useParams } from 'common'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { TableDetailSettingsTab } from '@/components/interfaces/Database/Tables/TableDetailSettingsTab'
|
||||
import { TableDetailLayout } from '@/components/layouts/DatabaseLayout/TableDetailLayout'
|
||||
import DefaultLayout from '@/components/layouts/DefaultLayout'
|
||||
import { useTableEditorQuery } from '@/data/table-editor/table-editor-query'
|
||||
import { isTableLike } from '@/data/table-editor/table-editor-types'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
const TableDetailSettingsPage: NextPageWithLayout = () => {
|
||||
const { id: _id } = useParams()
|
||||
const id = _id ? Number(_id) : undefined
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: selectedTable, isPending } = useTableEditorQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
id,
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return <ShimmeringLoader />
|
||||
}
|
||||
|
||||
if (selectedTable === undefined || !isTableLike(selectedTable)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <TableDetailSettingsTab table={selectedTable} />
|
||||
}
|
||||
|
||||
TableDetailSettingsPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<TableDetailLayout section="settings">{page}</TableDetailLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default TableDetailSettingsPage
|
||||
@@ -2,7 +2,7 @@ import { useParams } from 'common'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout'
|
||||
import { TableDetailLayout } from '@/components/layouts/DatabaseLayout/TableDetailLayout'
|
||||
import DefaultLayout from '@/components/layouts/DefaultLayout'
|
||||
import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
@@ -12,7 +12,7 @@ const TableStorageRedirect: NextPageWithLayout = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (ref && id) {
|
||||
router.replace(`/project/${ref}/database/tables/${id}`)
|
||||
router.replace(`/project/${ref}/database/tables/${id}/settings`)
|
||||
}
|
||||
}, [ref, id, router])
|
||||
|
||||
@@ -21,7 +21,7 @@ const TableStorageRedirect: NextPageWithLayout = () => {
|
||||
|
||||
TableStorageRedirect.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<DatabaseLayout title="Tables">{page}</DatabaseLayout>
|
||||
<TableDetailLayout section="settings">{page}</TableDetailLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user