mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 11:25:06 +03:00
App-level fixes that reproduce on BOTH the Next and TanStack builds — split out of #47657 (which stays TanStack-only) for reviewability. All were found by a full-site click-through of the dashboard. ## Invalid HTML nesting (React 19 "will cause a hydration error" console errors) - **FormLayout description rendered in a `<p>`** (`packages/ui-patterns`): consumers pass arbitrary JSX (the RowEditor's `created_at` timezone note passes a `<div>` with `<p>`s) → `<p>`-in-`<p>` / `<div>`-in-`<p>`. Container is now a `<div>` with identical classes (Tailwind preflight makes them render the same). - **Switch toggles nested inside Tooltip trigger buttons** (button-in-button) in ColumnEditor ("Allow Nullable" + "Is Unique"), ExtensionRow, and PublicationsTableItem → repo-standard `TooltipTrigger asChild` + `<div>` wrapper. - **Saved log queries rendered a `<div>` directly inside `<tbody>`** (`/logs/explorer/saved`) → rows are now proper `<tr><td colSpan>` wrappers; the component itself is untouched (it's valid in its sidebar usage). - **Nested anchors in observability metric cards**: a card-level `<Link>` wrapped MetricCard's "More information" `<Link>` (identical URLs) → the chevron affordance renders as a `<span>` when no `href` is passed; clicks bubble to the card link, tooltips preserved. Design-system standalone usage unaffected. - **`objectFit="cover"` passed to modern `next/image`** on the featured integration card (unknown-prop warning) — the className already had `object-cover`; prop dropped. ## Ghost dead-snippet after deletion Deleting the active SQL snippet left its id in `useDashboardHistory` (`history.sql`), so the "SQL Editor" nav item navigated to `/sql/<deleted-id>` — content fetch 404s, no editor pane renders, and a phantom tab reappears. Fixed both ends: delete flows now purge dashboard history (and the tabs store clears a stale `previewTabId`), and `/sql/[id]` treats a snippet 404 as "clean up + `router.replace` to `/sql/new` + toast" instead of rendering the dead state. Unit tests for the store/history cleanup. ## `pg-meta` migrations query 400s on every project load `ActivityStats` on project home runs the migrations list query, whose SQL was a bare `select * from supabase_migrations.schema_migrations` — that table only exists once a migration has run, so every other project logged a failed `?key=migrations` request on every load (visible in production consoles too). The SQL is now guarded with `to_regclass` + `query_to_xml` (same pattern as the advisor lints' `storage.buckets` guard), returning zero rows instead of erroring; legacy version-only tables still work. Tested against real dockerized Postgres (absent table, populated ordering, special chars, legacy schema) + MSW hook tests. Found and verified via /test-supabase-local (browser click-through + console audit on both builds). ## To test Console must stay free of React DOM-nesting errors ("cannot be a descendant of" / "cannot contain a nested") on each surface: 1. Table editor → Insert row panel (`created_at` field renders its timezone note) and Edit column panel ("Allow Nullable"/"Is Unique" tooltips still hover). 2. `/database/extensions` and `/database/publications` → toggle switches render, tooltips hover. 3. `/logs/explorer/saved` (with ≥1 saved query) → rows render full-width inside the table, hover shows Actions. 4. `/observability` → no nested-anchor error on load; card body click and the chevron both navigate; label help-icons still show tooltips. 5. `/integrations` → no `objectFit` unknown-prop warning; featured card images still cover. 6. **Ghost snippet**: open a SQL snippet → delete it via the sidebar → click the "SQL Editor" nav item → lands on `/sql/new` (no phantom tab, no 404 content fetch). Direct-load `/sql/<random-uuid>` → toast + redirect to `/sql/new`. 7. **Migrations 400**: load project home with a project that has never run a migration → the `pg-meta/<ref>/query?key=migrations` request returns **200** with `[]` (previously a 400 on every load). Database → Migrations still lists real migrations when they exist. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit * **Bug Fixes** * Deleted SQL snippets are fully removed from dashboard history and stale editor/tab state; users are redirected with a toast. * Closing preview tabs no longer leaves stale references. * Improved toggle/tooltip/dialog interactions to avoid broken UI, including metric headers showing tooltips even without direct links. * Migrations display safely when migration tables/relations are missing. * **UI Improvements** * Refreshed layout for saved queries, form descriptions, and integration imagery. * **Tests** * Added coverage for snippet history cleanup, tab removal, migrations SQL behavior, and query edge cases. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --- ### Review feedback: `query_to_xml` breaks on Multigres (Ivan) The defensive migrations query (added here to stop the `?key=migrations` 400 when the table doesn't exist yet) originally guarded with `query_to_xml`, which is forbidden through Multigres's pooler (MUL-736 / PSQL-1318). Rewritten without `query_to_xml`/`xmltable` using the splinter#170 pattern: a PL/pgSQL `do` block guarded by `to_regclass` (PL/pgSQL defers planning, so a missing table never errors) stashes the rows into a transaction-local GUC via `set_config`, and a trailing `select` reads them back with `jsonb_array_elements`. Verified that postgres-meta sends the whole SQL as one simple-query string → single implicit transaction → the local GUC survives to the `select` and doesn't leak into the pooled connection. 6/6 dockerized-Postgres tests (absent table → `[]`, populated/ordered/special-chars, legacy version-only table, full pg-meta-shaped multi-statement string, GUC non-leakage). Note (out of scope, pre-existing): `packages/pg-meta/src/sql/studio/advisor/lints.ts` still uses `query_to_xml` — a separate pre-existing Multigres risk that should get its own splinter-pattern sync. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> Co-authored-by: Joshen Lim <joshenlimek@gmail.com> Co-authored-by: Saxon Fletcher <saxonafletcher@gmail.com>
505 lines
15 KiB
TypeScript
505 lines
15 KiB
TypeScript
import { safeLocalStorage, useParams } from 'common'
|
|
import { partition } from 'lodash'
|
|
import { type NextRouter } from 'next/router'
|
|
import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'
|
|
import { proxy, subscribe, useSnapshot } from 'valtio'
|
|
|
|
import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils'
|
|
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
|
|
|
|
export const editorEntityTypes = {
|
|
table: ['r', 'v', 'm', 'f', 'p'],
|
|
sql: ['sql'],
|
|
}
|
|
|
|
export type TabType = ENTITY_TYPE | 'sql'
|
|
|
|
type CreateTabIdParams = {
|
|
r: { id: number }
|
|
v: { id: number }
|
|
m: { id: number }
|
|
f: { id: number }
|
|
p: { id: number }
|
|
sql: { id: string }
|
|
schema: { schema: string }
|
|
view: never
|
|
function: never
|
|
new: never
|
|
}
|
|
|
|
export interface Tab {
|
|
id: string
|
|
type: TabType
|
|
label?: string
|
|
metadata?: {
|
|
schema?: string
|
|
name?: string
|
|
tableId?: number
|
|
sqlId?: string
|
|
scrollTop?: number
|
|
}
|
|
isPreview?: boolean
|
|
createdAt?: Date
|
|
updatedAt?: Date
|
|
}
|
|
|
|
const MAX_RECENT_ITEMS = 8
|
|
|
|
export interface RecentItem {
|
|
id: string
|
|
type: TabType
|
|
label: string
|
|
timestamp: number
|
|
metadata?: {
|
|
schema?: string
|
|
name?: string
|
|
tableId?: number
|
|
sqlId?: string
|
|
}
|
|
}
|
|
|
|
const RECENT_ITEMS_STORAGE_KEY = 'supabase_recent_items'
|
|
const getRecentItemsStorageKey = (ref: string) => `${RECENT_ITEMS_STORAGE_KEY}_${ref}`
|
|
|
|
function getSavedRecentItems(ref: string): RecentItem[] {
|
|
if (!ref) return []
|
|
|
|
const stored = safeLocalStorage.getItem(getRecentItemsStorageKey(ref))
|
|
|
|
try {
|
|
return JSON.parse(stored ?? '{"items": []}').items
|
|
} catch (error) {
|
|
return []
|
|
}
|
|
}
|
|
|
|
const DEFAULT_TABS_STATE = {
|
|
activeTab: null as string | null,
|
|
openTabs: [] as string[],
|
|
tabsMap: {} as Record<string, Tab>,
|
|
previewTabId: undefined as string | undefined,
|
|
recentItems: [],
|
|
}
|
|
const TABS_STORAGE_KEY = 'supabase_studio_tabs'
|
|
const getTabsStorageKey = (ref: string) => `${TABS_STORAGE_KEY}_${ref}`
|
|
|
|
function getSavedTabs(ref: string) {
|
|
if (!ref) return DEFAULT_TABS_STATE
|
|
|
|
const stored = safeLocalStorage.getItem(getTabsStorageKey(ref))
|
|
|
|
if (!stored) return DEFAULT_TABS_STATE
|
|
|
|
try {
|
|
const parsed = JSON.parse(
|
|
stored ?? JSON.stringify(DEFAULT_TABS_STATE)
|
|
) as typeof DEFAULT_TABS_STATE
|
|
|
|
if (
|
|
!parsed.openTabs ||
|
|
!Array.isArray(parsed.openTabs) ||
|
|
!parsed.tabsMap ||
|
|
typeof parsed.tabsMap !== 'object'
|
|
) {
|
|
return DEFAULT_TABS_STATE
|
|
}
|
|
|
|
return parsed
|
|
} catch (error) {
|
|
return DEFAULT_TABS_STATE
|
|
}
|
|
}
|
|
|
|
const getRecentItemLabel = (tab: Pick<Tab, 'label' | 'metadata'>) =>
|
|
tab.label || tab.metadata?.name || 'Untitled'
|
|
|
|
const syncRecentItemWithTab = (item: RecentItem, tab: Pick<Tab, 'label' | 'metadata'>) => {
|
|
const nextLabel = getRecentItemLabel(tab)
|
|
|
|
item.label = nextLabel
|
|
item.metadata = {
|
|
...item.metadata,
|
|
...tab.metadata,
|
|
name: nextLabel,
|
|
}
|
|
}
|
|
|
|
export function createTabsState(projectRef: string) {
|
|
const recentItems = getSavedRecentItems(projectRef)
|
|
const { openTabs, activeTab, tabsMap, previewTabId } = getSavedTabs(projectRef)
|
|
|
|
const store = proxy({
|
|
// RECENT ITEMS
|
|
recentItems,
|
|
|
|
addRecentItem: (tab: Tab) => {
|
|
// Check if an item with the same ID already exists
|
|
const existingItem = store.recentItems.find((item) => item.id === tab.id)
|
|
|
|
if (existingItem) {
|
|
// If it exists, update its timestamp
|
|
existingItem.timestamp = Date.now()
|
|
syncRecentItemWithTab(existingItem, tab)
|
|
return // Exit the function
|
|
}
|
|
|
|
// If it doesn't exist, create and add a new item
|
|
const recentItem: RecentItem = {
|
|
id: tab.id, // Set the ID
|
|
type: tab.type, // Set the type
|
|
label: getRecentItemLabel(tab), // Set the label or default to 'Untitled'
|
|
timestamp: Date.now(), // Set the current timestamp
|
|
metadata: tab.metadata, // Set the metadata
|
|
}
|
|
|
|
// Add the new recent item to the beginning of the list
|
|
store.recentItems.unshift(recentItem)
|
|
|
|
// Ensure that there's only up to max of MAX_RECENT_ITEMS items per tab type
|
|
const [itemsOfSameType, itemsOfDifferentType] = partition(store.recentItems, (item) => {
|
|
if (editorEntityTypes.table.includes(item.type)) return item
|
|
})
|
|
store.recentItems = [...itemsOfSameType.slice(0, MAX_RECENT_ITEMS), ...itemsOfDifferentType]
|
|
},
|
|
clearRecentItems: () => {
|
|
store.recentItems = []
|
|
},
|
|
removeRecentItem: (itemId: string) => {
|
|
store.recentItems = store.recentItems.filter((item) => item.id !== itemId)
|
|
},
|
|
removeRecentItems: (itemIds: string[]) => {
|
|
store.recentItems = store.recentItems.filter((item) => !itemIds.includes(item.id))
|
|
},
|
|
removeRecentItemsByType: (type: TabType) => {
|
|
store.recentItems = store.recentItems.filter((item) => item.type !== type)
|
|
},
|
|
|
|
getRecentItemsByType: (type: TabType) => {
|
|
return store.recentItems.filter((item) => item.type === type)
|
|
},
|
|
|
|
// TABS
|
|
activeTab,
|
|
openTabs,
|
|
tabsMap,
|
|
previewTabId,
|
|
|
|
hasTab: (id: string) => {
|
|
return !!store.tabsMap[id]
|
|
},
|
|
addTab: (tab: Tab) => {
|
|
// If tab exists and is active, don't do anything
|
|
if (store.tabsMap[tab.id] && store.activeTab === tab.id) {
|
|
return
|
|
}
|
|
|
|
// If tab exists but isn't active, just make it active
|
|
if (store.tabsMap[tab.id]) {
|
|
store.activeTab = tab.id
|
|
if (!tab.isPreview) store.addRecentItem(tab)
|
|
return
|
|
}
|
|
|
|
// If this tab should be permanent, add it normally
|
|
if (tab.isPreview === false) {
|
|
store.openTabs = [...store.openTabs, tab.id]
|
|
store.tabsMap[tab.id] = tab
|
|
store.activeTab = tab.id
|
|
// Add to recent items when creating permanent tab
|
|
store.addRecentItem(tab)
|
|
return
|
|
}
|
|
|
|
// Remove any existing preview tab
|
|
if (store.previewTabId) {
|
|
store.openTabs = store.openTabs.filter((id) => id !== store.previewTabId)
|
|
delete store.tabsMap[store.previewTabId]
|
|
}
|
|
|
|
// Add new preview tab
|
|
store.tabsMap[tab.id] = { ...tab, isPreview: true }
|
|
store.openTabs = [...store.openTabs, tab.id]
|
|
store.previewTabId = tab.id
|
|
store.activeTab = tab.id
|
|
},
|
|
updateTab: (id: string, updates: { label?: string; scrollTop?: number }) => {
|
|
if (!!store.tabsMap[id]) {
|
|
if ('label' in updates) {
|
|
store.tabsMap[id].label = updates.label
|
|
// Keep the persisted name aligned with the visible label so browser titles
|
|
// and tab state recover cleanly after entity renames.
|
|
if (typeof updates.label === 'string' && store.tabsMap[id].metadata) {
|
|
store.tabsMap[id].metadata.name = updates.label
|
|
}
|
|
|
|
const recentItem = store.recentItems.find((item) => item.id === id)
|
|
if (recentItem) syncRecentItemWithTab(recentItem, store.tabsMap[id])
|
|
}
|
|
if ('scrollTop' in updates && store.tabsMap[id].metadata) {
|
|
store.tabsMap[id].metadata.scrollTop = updates.scrollTop
|
|
}
|
|
}
|
|
},
|
|
// Function to remove a tab from the store
|
|
// this is used for removing tabs from the localstorage state
|
|
// for handling a manual tab removal with a close action, use handleTabClose()
|
|
removeTab: (id: string) => {
|
|
const idx = store.openTabs.indexOf(id)
|
|
store.openTabs = store.openTabs.filter((tabId) => tabId !== id)
|
|
delete store.tabsMap[id]
|
|
|
|
// Clear the preview tab reference if the removed tab was the preview tab,
|
|
// so it doesn't linger in (persisted) state pointing at a closed tab
|
|
if (store.previewTabId === id) {
|
|
store.previewTabId = undefined
|
|
}
|
|
|
|
// Update active tab if the removed tab was active
|
|
if (id === store.activeTab) {
|
|
store.activeTab = store.openTabs[idx - 1] || store.openTabs[idx + 1] || null
|
|
}
|
|
},
|
|
|
|
// Function to remove multiple tabs from the store
|
|
// this is used for removing tabs from the localstorage state
|
|
// for handling a manual tab removal with a close action, use handleTabClose()
|
|
removeTabs: (ids: string[]) => {
|
|
if (!ids.length) return
|
|
|
|
ids.forEach((id) => store.removeTab(id))
|
|
},
|
|
reorderTabs: (oldIndex: number, newIndex: number) => {
|
|
const newOpenTabs = [...store.openTabs]
|
|
const [removedTab] = newOpenTabs.splice(oldIndex, 1)
|
|
newOpenTabs.splice(newIndex, 0, removedTab)
|
|
store.openTabs = newOpenTabs
|
|
},
|
|
makeTabActive: (tabId: string) => {
|
|
const tab = store.tabsMap[tabId]
|
|
if (!tab) return
|
|
store.activeTab = tab.id
|
|
},
|
|
makeTabPermanent: (tabId: string) => {
|
|
const tab = store.tabsMap[tabId]
|
|
|
|
if (tab?.isPreview) {
|
|
tab.isPreview = false
|
|
store.previewTabId = undefined
|
|
// Add to recent items when preview tab becomes permanent
|
|
store.addRecentItem(tab)
|
|
}
|
|
},
|
|
makeActiveTabPermanent: () => {
|
|
if (store.activeTab && store.tabsMap[store.activeTab]?.isPreview) {
|
|
store.makeTabPermanent(store.activeTab)
|
|
return true
|
|
}
|
|
return false
|
|
},
|
|
|
|
// TABS HANDLERS
|
|
|
|
handleTabNavigation: (id: string, router: NextRouter) => {
|
|
const tab = store.tabsMap[id]
|
|
if (!tab) return
|
|
|
|
store.activeTab = id
|
|
|
|
// Add to recent items when navigating to a non-preview, non-new tab
|
|
if (!tab.isPreview) store.addRecentItem(tab)
|
|
|
|
switch (tab.type) {
|
|
case 'sql':
|
|
const schema = (router.query.schema as string) || 'public'
|
|
router.push(`/project/${router.query.ref}/sql/${tab.metadata?.sqlId}?schema=${schema}`)
|
|
break
|
|
case 'r':
|
|
case 'v':
|
|
case 'm':
|
|
case 'f':
|
|
case 'p':
|
|
router.push(
|
|
buildTableEditorUrl({
|
|
projectRef: router.query.ref as string,
|
|
tableId: tab.metadata?.tableId!,
|
|
schema: tab.metadata?.schema,
|
|
})
|
|
)
|
|
break
|
|
}
|
|
},
|
|
handleTabClose: ({
|
|
id,
|
|
router,
|
|
editor,
|
|
onClose,
|
|
onClearDashboardHistory,
|
|
}: {
|
|
id: string
|
|
router: NextRouter
|
|
editor?: 'sql' | 'table'
|
|
onClose?: (id: string) => void
|
|
onClearDashboardHistory: () => void
|
|
}) => {
|
|
const tabBeingClosed = store.tabsMap[id]
|
|
|
|
const editorTabIds = (
|
|
editor
|
|
? Object.values(store.tabsMap).filter((tab) =>
|
|
editorEntityTypes[editor]?.includes(tab.type)
|
|
)
|
|
: []
|
|
).map((tab) => tab.id)
|
|
const tabIndexBeingClosed = editorTabIds.indexOf(id)
|
|
const isLastTabBeingClosed = tabIndexBeingClosed === editorTabIds.length - 1
|
|
const nextTabId =
|
|
editorTabIds.length === 1
|
|
? undefined
|
|
: isLastTabBeingClosed
|
|
? editorTabIds[tabIndexBeingClosed - 1]
|
|
: editorTabIds[tabIndexBeingClosed + 1]
|
|
|
|
const { [id]: value, ...otherTabs } = store.tabsMap
|
|
store.tabsMap = otherTabs
|
|
|
|
if (tabBeingClosed) {
|
|
const updatedOpenTabs = [...store.openTabs].filter((x) => x !== id)
|
|
store.openTabs = updatedOpenTabs
|
|
}
|
|
|
|
// Remove the preview tab if it matches the tab being closed
|
|
if (store.previewTabId === id) {
|
|
store.previewTabId = undefined
|
|
}
|
|
|
|
// [Joshen] Only navigate away if we're closing the tab that's currently in focus
|
|
if (store.activeTab === id || id === 'new') {
|
|
if (nextTabId) {
|
|
store.activeTab = nextTabId
|
|
store.handleTabNavigation(nextTabId, router)
|
|
} else {
|
|
onClearDashboardHistory()
|
|
|
|
// If no tabs of same type, go to the home of the current section
|
|
switch (tabBeingClosed?.type) {
|
|
case 'sql':
|
|
router.push(`/project/${router.query.ref}/sql`)
|
|
break
|
|
case 'r':
|
|
case 'v':
|
|
case 'm':
|
|
case 'f':
|
|
case 'p':
|
|
router.push(`/project/${router.query.ref}/editor`)
|
|
break
|
|
default:
|
|
router.push(`/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
onClose?.(id)
|
|
},
|
|
handleTabCloseAll: ({
|
|
editor,
|
|
router,
|
|
onClearDashboardHistory,
|
|
}: {
|
|
editor: 'sql' | 'table'
|
|
router: NextRouter
|
|
onClearDashboardHistory: () => void
|
|
}) => {
|
|
const tabsToClose =
|
|
editor === 'table'
|
|
? store.openTabs.filter((x) => !x.startsWith('sql'))
|
|
: store.openTabs.filter((x) => x.startsWith('sql'))
|
|
store.removeTabs(tabsToClose)
|
|
onClearDashboardHistory()
|
|
router.push(`/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}`)
|
|
},
|
|
handleTabDragEnd: (oldIndex: number, newIndex: number, tabId: string, router: NextRouter) => {
|
|
// Make permanent if needed
|
|
const draggedTab = store.tabsMap[tabId]
|
|
if (draggedTab?.isPreview) {
|
|
store.makeTabPermanent(tabId)
|
|
}
|
|
|
|
// Reorder tabs
|
|
const newOpenTabs = [...store.openTabs]
|
|
newOpenTabs.splice(oldIndex, 1)
|
|
newOpenTabs.splice(newIndex, 0, tabId)
|
|
|
|
store.openTabs = newOpenTabs
|
|
store.activeTab = tabId
|
|
|
|
// Handle navigation
|
|
store.handleTabNavigation(tabId, router)
|
|
},
|
|
})
|
|
|
|
return store
|
|
}
|
|
|
|
export type TabsState = ReturnType<typeof createTabsState>
|
|
|
|
export const TabsStateContext = createContext<TabsState>(createTabsState(''))
|
|
|
|
export const TabsStateContextProvider = ({ children }: PropsWithChildren) => {
|
|
const { ref: projectRef } = useParams()
|
|
const [state, setState] = useState(createTabsState(projectRef ?? ''))
|
|
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined' && !!projectRef) {
|
|
setState(createTabsState(projectRef ?? ''))
|
|
}
|
|
}, [projectRef])
|
|
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined' && projectRef) {
|
|
return subscribe(state, () => {
|
|
safeLocalStorage.setItem(
|
|
getTabsStorageKey(projectRef),
|
|
JSON.stringify({
|
|
activeTab: state.activeTab,
|
|
openTabs: state.openTabs,
|
|
tabsMap: state.tabsMap,
|
|
previewTabId: state.previewTabId,
|
|
})
|
|
)
|
|
safeLocalStorage.setItem(
|
|
getRecentItemsStorageKey(projectRef),
|
|
JSON.stringify({
|
|
items: state.recentItems,
|
|
})
|
|
)
|
|
})
|
|
}
|
|
}, [projectRef, state])
|
|
|
|
return <TabsStateContext.Provider value={state}>{children}</TabsStateContext.Provider>
|
|
}
|
|
|
|
export const useTabsStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) => {
|
|
const state = useContext(TabsStateContext)
|
|
return useSnapshot(state, options)
|
|
}
|
|
|
|
export function createTabId<T extends TabType>(type: T, params: CreateTabIdParams[T]): string {
|
|
switch (type) {
|
|
case 'r':
|
|
return `r-${(params as CreateTabIdParams['r']).id}`
|
|
case 'v':
|
|
return `v-${(params as CreateTabIdParams['v']).id}`
|
|
case 'm':
|
|
return `m-${(params as CreateTabIdParams['m']).id}`
|
|
case 'f':
|
|
return `f-${(params as CreateTabIdParams['f']).id}`
|
|
case 'p':
|
|
return `p-${(params as CreateTabIdParams['p']).id}`
|
|
case 'sql':
|
|
return `sql-${(params as CreateTabIdParams['sql']).id}`
|
|
default:
|
|
return ''
|
|
}
|
|
}
|