mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio) instrument sentry on table creation flow (#43673)
(working with @charislam @awaseem on this one) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES Per [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md): - ✅ `pnpm --filter studio build` - ✅ `pnpm test:prettier` - ✅ `pnpm format` ## What kind of change does this PR introduce? Instruments `studio` app's table creation flow with Sentry. ## What is the current behavior? The table creation flow isn't instrumented in detail, which means we have less visibility into understanding performance problems or errors within this specific flow. ## What is the new behavior? The table creation flow is instrumented in detail. This instrumentation allows for Sentry dashboards like this one (created based on locally generated Supabase data), to help monitor anything going wrong with this critical flow: <img width="1915" height="1131" alt="image" src="https://github.com/user-attachments/assets/e2c5380e-7b00-41c1-9109-3ee4e47ff63b" /> --------- Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
1 parent
edf51dfd98
commit
e217f747bd
4 files changed
+316
-195
No files matched your search
+126
-54
@@ -1,4 +1,5 @@
|
||||
import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta'
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import {
|
||||
@@ -680,66 +681,137 @@ export const SidePanelEditor = ({
|
||||
|
||||
try {
|
||||
if (action === 'create') {
|
||||
toastId = toast.loading(`Creating new table: ${payload.name}...`)
|
||||
await Sentry.startSpan(
|
||||
{
|
||||
name: 'Create Table',
|
||||
op: 'db.table.create',
|
||||
},
|
||||
async (createTableSpan) => {
|
||||
toastId = toast.loading(`Creating new table: ${payload.name}...`)
|
||||
|
||||
const { table, failedPolicies } = await createTable({
|
||||
projectRef: project?.ref!,
|
||||
connectionString: project?.connectionString,
|
||||
toastId,
|
||||
payload,
|
||||
columns,
|
||||
foreignKeyRelations,
|
||||
isRLSEnabled,
|
||||
importContent,
|
||||
organizationSlug: org?.slug,
|
||||
generatedPolicies,
|
||||
onCreatePoliciesSuccess: () => track('rls_generated_policies_created'),
|
||||
})
|
||||
// Get existing table count from cache — try entity types first (always loaded
|
||||
// by the Table Editor sidebar), then fall back to tables query cache.
|
||||
// Entity types uses useInfiniteQuery, so the cache shape is { pages: [...] }.
|
||||
// Each page has data.count (total count from SQL count(*) over()).
|
||||
const entityTypesEntries = queryClient.getQueriesData<{
|
||||
pages?: Array<{ data?: { count?: number } }>
|
||||
}>({
|
||||
queryKey: ['projects', project?.ref, 'entity-types'],
|
||||
})
|
||||
const existingTableCount =
|
||||
entityTypesEntries
|
||||
.map(([, data]) => data?.pages?.[0]?.data?.count)
|
||||
.find((count) => typeof count === 'number') ??
|
||||
queryClient.getQueryData<unknown[]>(
|
||||
tableKeys.list(project?.ref, payload.schema, true)
|
||||
)?.length ??
|
||||
queryClient.getQueryData<unknown[]>(
|
||||
tableKeys.list(project?.ref, payload.schema, false)
|
||||
)?.length
|
||||
|
||||
if (isRealtimeEnabled) await updateTableRealtime(table, true)
|
||||
createTableSpan.setAttributes({
|
||||
'table.name': payload.name,
|
||||
'table.schema': payload.schema ?? 'public',
|
||||
'table.columns_count': columns.length,
|
||||
'table.has_rls': isRLSEnabled ? 1 : 0,
|
||||
'table.has_foreign_keys': foreignKeyRelations.length > 0 ? 1 : 0,
|
||||
'table.has_import': importContent !== undefined ? 1 : 0,
|
||||
'table.generated_policies_count': generatedPolicies.length,
|
||||
'project.region': project?.region ?? 'local',
|
||||
...(project?.cloud_provider && {
|
||||
'project.cloud_provider': project.cloud_provider,
|
||||
}),
|
||||
...(existingTableCount != null && {
|
||||
'project.existing_table_count': String(existingTableCount),
|
||||
}),
|
||||
})
|
||||
|
||||
if (isApiGrantTogglesEnabled) {
|
||||
const privilegesToSet = apiAccessToggleHandler.data?.schemaExposed
|
||||
? apiAccessToggleHandler.data.privileges
|
||||
: undefined
|
||||
if (privilegesToSet) {
|
||||
await updateTableApiAccess(table, privilegesToSet)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const { table, failedPolicies } = await createTable({
|
||||
projectRef: project?.ref!,
|
||||
connectionString: project?.connectionString,
|
||||
toastId,
|
||||
payload,
|
||||
columns,
|
||||
foreignKeyRelations,
|
||||
isRLSEnabled,
|
||||
importContent,
|
||||
organizationSlug: org?.slug,
|
||||
generatedPolicies,
|
||||
onCreatePoliciesSuccess: () => track('rls_generated_policies_created'),
|
||||
})
|
||||
|
||||
// Invalidate queries for table creation
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tableKeys.list(project?.ref, table.schema, includeColumns),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(project?.ref) }),
|
||||
queryClient.invalidateQueries({ queryKey: databasePoliciesKeys.list(project?.ref) }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: privilegeKeys.tablePrivilegesList(project?.ref),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: lintKeys.lint(project?.ref) }),
|
||||
])
|
||||
createTableSpan.setAttribute('table.created', 1)
|
||||
createTableSpan.setAttribute('table.failed_policies', failedPolicies.length)
|
||||
|
||||
// Show success toast after everything is complete
|
||||
if (failedPolicies.length > 0) {
|
||||
toast.success(
|
||||
`Table ${table.name} is created successfully, but we ran into issues creating ${failedPolicies.length} policie${failedPolicies.length > 1 ? 's' : ''}`,
|
||||
{
|
||||
id: toastId,
|
||||
description: (
|
||||
<ul className="list-disc pl-6">
|
||||
{failedPolicies.map((x) => (
|
||||
<li key={x.name}>{x.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
await Sentry.startSpan(
|
||||
{ name: 'create_table.post_creation', op: 'db.table.post_creation' },
|
||||
async () => {
|
||||
if (isRealtimeEnabled) await updateTableRealtime(table, true)
|
||||
|
||||
if (isApiGrantTogglesEnabled) {
|
||||
const privilegesToSet = apiAccessToggleHandler.data?.schemaExposed
|
||||
? apiAccessToggleHandler.data.privileges
|
||||
: undefined
|
||||
if (privilegesToSet) {
|
||||
await updateTableApiAccess(table, privilegesToSet)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Invalidate queries for table creation
|
||||
await Sentry.startSpan(
|
||||
{ name: 'create_table.cache_invalidation', op: 'cache.invalidate' },
|
||||
async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tableKeys.list(project?.ref, table.schema, includeColumns),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: entityTypeKeys.list(project?.ref),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: databasePoliciesKeys.list(project?.ref),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: privilegeKeys.tablePrivilegesList(project?.ref),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: lintKeys.lint(project?.ref) }),
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
// Show success toast after everything is complete
|
||||
if (failedPolicies.length > 0) {
|
||||
toast.success(
|
||||
`Table ${table.name} is created successfully, but we ran into issues creating ${failedPolicies.length} policie${failedPolicies.length > 1 ? 's' : ''}`,
|
||||
{
|
||||
id: toastId,
|
||||
description: (
|
||||
<ul className="list-disc pl-6">
|
||||
{failedPolicies.map((x) => (
|
||||
<li key={x.name}>{x.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
}
|
||||
)
|
||||
} else {
|
||||
toast.success(`Table ${table.name} is good to go!`, { id: toastId })
|
||||
}
|
||||
|
||||
onTableCreated(table)
|
||||
} catch (error) {
|
||||
createTableSpan.setAttribute('table.error', 1)
|
||||
Sentry.captureException(error, {
|
||||
tags: { workflow: 'create-table' },
|
||||
})
|
||||
saveTableError = true
|
||||
throw error
|
||||
}
|
||||
)
|
||||
} else {
|
||||
toast.success(`Table ${table.name} is good to go!`, { id: toastId })
|
||||
}
|
||||
|
||||
onTableCreated(table)
|
||||
}
|
||||
)
|
||||
} else if (action === 'duplicate' && !!selectedTable) {
|
||||
const tableToDuplicate = selectedTable
|
||||
toastId = toast.loading(`Duplicating table: ${tableToDuplicate.name}...`)
|
||||
|
||||
+152
-116
@@ -1,3 +1,4 @@
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import pgMeta from '@supabase/pg-meta'
|
||||
import { Query } from '@supabase/pg-meta/src/query'
|
||||
import type { PostgresPrimaryKey } from '@supabase/postgres-meta'
|
||||
@@ -612,12 +613,18 @@ export const createTable = async ({
|
||||
// Execute all table creation SQL in a single transaction
|
||||
toast.loading(`Creating table ${payload.name}...`, { id: toastId })
|
||||
|
||||
await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: sqlStatements.join(';\n'),
|
||||
queryKey: ['table', 'create-with-columns'],
|
||||
})
|
||||
await Sentry.startSpan(
|
||||
{ name: 'create_table.execute_sql', op: 'db.sql.transaction' },
|
||||
async (span) => {
|
||||
span.setAttribute('sql.statement_count', sqlStatements.length)
|
||||
await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: sqlStatements.join(';\n'),
|
||||
queryKey: ['table', 'create-with-columns'],
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// 6. Create generated RLS policies if any
|
||||
// [Joshen] Possible area for optimization to create all policies in a single query call
|
||||
@@ -625,31 +632,38 @@ export const createTable = async ({
|
||||
|
||||
const failedPolicies: GeneratedPolicy[] = []
|
||||
if (generatedPolicies.length > 0 && isRLSEnabled) {
|
||||
toast.loading(`Creating ${generatedPolicies.length} policies for table...`, { id: toastId })
|
||||
await Promise.all(
|
||||
generatedPolicies.map(async (policy) => {
|
||||
try {
|
||||
return await createDatabasePolicy({
|
||||
projectRef,
|
||||
connectionString,
|
||||
payload: {
|
||||
name: policy.name,
|
||||
table: policy.table,
|
||||
schema: policy.schema,
|
||||
definition: policy.definition,
|
||||
check: policy.check,
|
||||
action: policy.action,
|
||||
command: policy.command,
|
||||
roles: policy.roles,
|
||||
},
|
||||
await Sentry.startSpan(
|
||||
{ name: 'create_table.create_policies', op: 'db.policies.create' },
|
||||
async (span) => {
|
||||
span.setAttribute('policies.count', generatedPolicies.length)
|
||||
toast.loading(`Creating ${generatedPolicies.length} policies for table...`, { id: toastId })
|
||||
await Promise.all(
|
||||
generatedPolicies.map(async (policy) => {
|
||||
try {
|
||||
return await createDatabasePolicy({
|
||||
projectRef,
|
||||
connectionString,
|
||||
payload: {
|
||||
name: policy.name,
|
||||
table: policy.table,
|
||||
schema: policy.schema,
|
||||
definition: policy.definition,
|
||||
check: policy.check,
|
||||
action: policy.action,
|
||||
command: policy.command,
|
||||
roles: policy.roles,
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Failed to generate policy', error.message)
|
||||
failedPolicies.push(policy)
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Failed to generate policy', error.message)
|
||||
failedPolicies.push(policy)
|
||||
}
|
||||
})
|
||||
)
|
||||
span.setAttribute('policies.failed_count', failedPolicies.length)
|
||||
onCreatePoliciesSuccess?.()
|
||||
}
|
||||
)
|
||||
onCreatePoliciesSuccess?.()
|
||||
}
|
||||
|
||||
// Track table creation event (fire-and-forget to avoid blocking)
|
||||
@@ -692,102 +706,124 @@ export const createTable = async ({
|
||||
}
|
||||
|
||||
// Fetch the created table
|
||||
const table = await getTableQuery({
|
||||
projectRef,
|
||||
connectionString,
|
||||
name: payload.name,
|
||||
schema: payload.schema,
|
||||
})
|
||||
const table = await Sentry.startSpan(
|
||||
{ name: 'create_table.fetch_table', op: 'db.table.fetch' },
|
||||
async () => {
|
||||
return await getTableQuery({
|
||||
projectRef,
|
||||
connectionString,
|
||||
name: payload.name,
|
||||
schema: payload.schema,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// If the user is importing data via a spreadsheet
|
||||
if (importContent !== undefined) {
|
||||
if (importContent.file && importContent.rowCount > 0) {
|
||||
// Via a CSV file
|
||||
const { error }: any = await insertRowsViaSpreadsheet(
|
||||
projectRef,
|
||||
connectionString,
|
||||
importContent.file,
|
||||
table,
|
||||
importContent.selectedHeaders,
|
||||
(progress: number) => {
|
||||
toast.loading(
|
||||
<div className="flex flex-col space-y-2" style={{ minWidth: '220px' }}>
|
||||
<SparkBar
|
||||
value={progress}
|
||||
max={100}
|
||||
type="horizontal"
|
||||
barClass="bg-brand"
|
||||
labelBottom={`Adding ${importContent.rowCount.toLocaleString()} rows to ${table.name}`}
|
||||
labelBottomClass=""
|
||||
labelTop={`${progress.toFixed(2)}%`}
|
||||
labelTopClass="tabular-nums"
|
||||
/>
|
||||
</div>,
|
||||
{ id: toastId }
|
||||
await Sentry.startSpan(
|
||||
{ name: 'create_table.import_data', op: 'db.table.import' },
|
||||
async (span) => {
|
||||
const rowCount = importContent.file
|
||||
? importContent.rowCount
|
||||
: importContent.rows?.length ?? 0
|
||||
span.setAttribute('import.row_count', rowCount)
|
||||
span.setAttribute('import.method', importContent.file ? 'csv' : 'paste')
|
||||
|
||||
if (importContent.file && importContent.rowCount > 0) {
|
||||
// Via a CSV file
|
||||
const { error }: any = await insertRowsViaSpreadsheet(
|
||||
projectRef,
|
||||
connectionString,
|
||||
importContent.file,
|
||||
table,
|
||||
importContent.selectedHeaders,
|
||||
(progress: number) => {
|
||||
toast.loading(
|
||||
<div className="flex flex-col space-y-2" style={{ minWidth: '220px' }}>
|
||||
<SparkBar
|
||||
value={progress}
|
||||
max={100}
|
||||
type="horizontal"
|
||||
barClass="bg-brand"
|
||||
labelBottom={`Adding ${importContent.rowCount.toLocaleString()} rows to ${table.name}`}
|
||||
labelBottomClass=""
|
||||
labelTop={`${progress.toFixed(2)}%`}
|
||||
labelTopClass="tabular-nums"
|
||||
/>
|
||||
</div>,
|
||||
{ id: toastId }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
if (error !== undefined) {
|
||||
span.setAttribute('import.error', 1)
|
||||
toast.error('Do check your spreadsheet if there are any discrepancies.')
|
||||
const message = `Table ${table.name} has been created but we ran into an error while inserting rows: ${error.message}`
|
||||
toast.error(message)
|
||||
console.error('Error:', { error, message })
|
||||
}
|
||||
} else {
|
||||
// Via text copy and paste
|
||||
await insertTableRows(
|
||||
projectRef,
|
||||
connectionString,
|
||||
table,
|
||||
importContent.rows,
|
||||
importContent.selectedHeaders,
|
||||
(progress: number) => {
|
||||
toast.loading(
|
||||
<div className="flex flex-col space-y-2" style={{ minWidth: '220px' }}>
|
||||
<SparkBar
|
||||
value={progress}
|
||||
max={100}
|
||||
type="horizontal"
|
||||
barClass="bg-brand"
|
||||
labelBottom={`Adding ${importContent.rows.length.toLocaleString()} rows to ${table.name}`}
|
||||
labelTop={`${progress.toFixed(2)}%`}
|
||||
labelTopClass="tabular-nums"
|
||||
/>
|
||||
</div>,
|
||||
{ id: toastId }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
if (error !== undefined) {
|
||||
toast.error('Do check your spreadsheet if there are any discrepancies.')
|
||||
const message = `Table ${table.name} has been created but we ran into an error while inserting rows: ${error.message}`
|
||||
toast.error(message)
|
||||
console.error('Error:', { error, message })
|
||||
}
|
||||
} else {
|
||||
// Via text copy and paste
|
||||
await insertTableRows(
|
||||
projectRef,
|
||||
connectionString,
|
||||
table,
|
||||
importContent.rows,
|
||||
importContent.selectedHeaders,
|
||||
(progress: number) => {
|
||||
toast.loading(
|
||||
<div className="flex flex-col space-y-2" style={{ minWidth: '220px' }}>
|
||||
<SparkBar
|
||||
value={progress}
|
||||
max={100}
|
||||
type="horizontal"
|
||||
barClass="bg-brand"
|
||||
labelBottom={`Adding ${importContent.rows.length.toLocaleString()} rows to ${table.name}`}
|
||||
labelTop={`${progress.toFixed(2)}%`}
|
||||
labelTopClass="tabular-nums"
|
||||
/>
|
||||
</div>,
|
||||
{ id: toastId }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// For identity columns, manually raise the sequences (batched for performance)
|
||||
const identityColumns = columns.filter((column) => column.isIdentity)
|
||||
if (identityColumns.length > 0) {
|
||||
const updateSequenceSQL = identityColumns
|
||||
.map((column) =>
|
||||
getUpdateIdentitySequenceSQL({
|
||||
schema: table.schema,
|
||||
table: table.name,
|
||||
column: column.name,
|
||||
// For identity columns, manually raise the sequences (batched for performance)
|
||||
const identityColumns = columns.filter((column) => column.isIdentity)
|
||||
if (identityColumns.length > 0) {
|
||||
const updateSequenceSQL = identityColumns
|
||||
.map((column) =>
|
||||
getUpdateIdentitySequenceSQL({
|
||||
schema: table.schema,
|
||||
table: table.name,
|
||||
column: column.name,
|
||||
})
|
||||
)
|
||||
.join(';\n')
|
||||
await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: updateSequenceSQL,
|
||||
queryKey: ['sequences', 'update-batch'],
|
||||
})
|
||||
)
|
||||
.join(';\n')
|
||||
await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: updateSequenceSQL,
|
||||
queryKey: ['sequences', 'update-batch'],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
await prefetchEditorTablePage({
|
||||
queryClient,
|
||||
projectRef,
|
||||
connectionString,
|
||||
id: table.id,
|
||||
})
|
||||
await Sentry.startSpan(
|
||||
{ name: 'create_table.prefetch_editor', op: 'db.table.prefetch' },
|
||||
async () => {
|
||||
await prefetchEditorTablePage({
|
||||
queryClient,
|
||||
projectRef,
|
||||
connectionString,
|
||||
id: table.id,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Finally, return the created table
|
||||
return { table, failedPolicies }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import { PG_META_URL } from 'lib/constants/index'
|
||||
import { constructHeaders } from '../apiHelpers'
|
||||
import { PgMetaDatabaseError, databaseErrorSchema, WrappedResult } from './types'
|
||||
@@ -31,30 +32,36 @@ export async function executeQuery<T = unknown>({
|
||||
requestBody.parameters = parameters
|
||||
}
|
||||
|
||||
const response = await fetch(`${PG_META_URL}/query`, {
|
||||
method: 'POST',
|
||||
headers: constructHeaders({
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
'x-connection-encrypted': connectionStringEncrypted,
|
||||
}),
|
||||
body: JSON.stringify(requestBody),
|
||||
return await Sentry.startSpan({ name: 'pg-meta.query', op: 'db.query' }, async (span) => {
|
||||
const response = await fetch(`${PG_META_URL}/query`, {
|
||||
method: 'POST',
|
||||
headers: constructHeaders({
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
'x-connection-encrypted': connectionStringEncrypted,
|
||||
}),
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
const { message, code, formattedError } = databaseErrorSchema.parse(result)
|
||||
span.setAttribute('db.error', 1)
|
||||
span.setAttribute('db.status_code', response.status)
|
||||
const error = new PgMetaDatabaseError(message, code, response.status, formattedError)
|
||||
return { data: undefined, error }
|
||||
}
|
||||
|
||||
span.setAttribute('db.status_code', response.status)
|
||||
return { data: result, error: undefined }
|
||||
} catch (error) {
|
||||
span.setAttribute('db.error', 1)
|
||||
if (error instanceof Error) {
|
||||
return { data: undefined, error }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
const { message, code, formattedError } = databaseErrorSchema.parse(result)
|
||||
const error = new PgMetaDatabaseError(message, code, response.status, formattedError)
|
||||
return { data: undefined, error }
|
||||
}
|
||||
|
||||
return { data: result, error: undefined }
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return { data: undefined, error }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PostgresColumn } from '@supabase/postgres-meta'
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import { useConstant } from 'common'
|
||||
import type { SupaRow } from 'components/grid/types'
|
||||
import {
|
||||
@@ -105,6 +106,11 @@ export const createTableEditorState = () => {
|
||||
|
||||
/* Tables */
|
||||
onAddTable: (templateData?: Partial<TableField>) => {
|
||||
// Record that the table creator was opened
|
||||
Sentry.startSpan({ name: 'table_creator.opened', op: 'ui.action' }, (span) => {
|
||||
span.setAttribute('table_creator.opened', 1)
|
||||
})
|
||||
|
||||
state.ui = {
|
||||
open: 'side-panel',
|
||||
sidePanel: { type: 'table', mode: 'new', templateData },
|
||||
|
||||
Reference in new issue
Block a user