From d0e20a60e640b8e0cf23d775097fe27fff93330b Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 10 Jul 2026 19:31:20 -0400 Subject: [PATCH 1/2] feat(databases): tree nav, database overview, and shared table actions Rebuild the Databases left nav on the applications tab's react-complex-tree: databases as top-level items with their tables nested, a "+ Create a Table" row, and built-in type-to-filter. Single-click selects (database -> new overview page, table -> data grid); double-click / chevron expands. - Add a database overview page: DB size, audit size, table count, and a per-table breakdown (size, columns, primary key, last updated) with record counts backfilled lazily. Landing on the tab now shows the first DB's overview. - Right-click menus for databases (Create a Table, Drop Database) and tables (Add Record(s), Import Data, Export CSV, Drop Table). The table actions live in one TableContextMenuItems component shared by the tree and the overview's table list, so the two menus can't drift. - Lift the action modals into a single always-mounted DatabaseActionModals hub driven by target-carrying watched values, so an action can target any db/table -- not just the open one. Extract the CSV download into useExportTableCsv. - Fix a Radix quirk where an already-open context menu didn't re-anchor on a second right-click: remount the content keyed by cursor position. Also applied to the applications file tree, which had the same bug. - Fix a latent crash in useInstanceSchemaTablePermission (missing optional chain) that the new right-click menus would trigger for restricted users. - Remove the redundant sidebar "Import Data" button (already in the header). Co-Authored-By: Claude Opus 4.8 --- .../FileTreeContextMenu.tsx | 13 +- .../ApplicationsSidebar/ItemArrow.tsx | 6 +- .../components/DatabaseActionModals.tsx | 104 +++++++++++ .../databases/components/DatabaseOverview.tsx | 165 +++++++++++++++++ .../components/DatabaseTableView.tsx | 125 +++---------- .../databases/components/DatabasesSidebar.tsx | 167 +----------------- .../DatabasesTree/DatabaseTreeContextMenu.tsx | 92 ++++++++++ .../components/DatabasesTree/ItemTitle.tsx | 28 +++ .../DatabasesTree/buildItems.test.ts | 67 +++++++ .../components/DatabasesTree/buildItems.ts | 73 ++++++++ .../components/DatabasesTree/getItemTitle.ts | 15 ++ .../components/DatabasesTree/index.tsx | 95 ++++++++++ .../components/DatabasesTree/specialItems.ts | 2 + .../DatabasesTree/useDatabaseTreeViewState.ts | 48 +++++ .../components/TableContextMenuItems.tsx | 65 +++++++ .../components/TableRowContextMenu.tsx | 55 ++++++ .../databases/hooks/useExportTableCsv.ts | 68 +++++++ src/features/instance/databases/index.tsx | 87 +++++---- .../databases/modals/CreateNewTableModal.tsx | 34 ++-- .../databases/modals/DeleteDatabaseModal.tsx | 11 +- .../databases/modals/DeleteTableModal.tsx | 13 +- src/hooks/usePermissions.ts | 4 +- src/lib/storage/sessionStorageKeys.ts | 1 + src/lib/storage/watchedValueKeys.ts | 9 +- 24 files changed, 1015 insertions(+), 332 deletions(-) create mode 100644 src/features/instance/databases/components/DatabaseActionModals.tsx create mode 100644 src/features/instance/databases/components/DatabaseOverview.tsx create mode 100644 src/features/instance/databases/components/DatabasesTree/DatabaseTreeContextMenu.tsx create mode 100644 src/features/instance/databases/components/DatabasesTree/ItemTitle.tsx create mode 100644 src/features/instance/databases/components/DatabasesTree/buildItems.test.ts create mode 100644 src/features/instance/databases/components/DatabasesTree/buildItems.ts create mode 100644 src/features/instance/databases/components/DatabasesTree/getItemTitle.ts create mode 100644 src/features/instance/databases/components/DatabasesTree/index.tsx create mode 100644 src/features/instance/databases/components/DatabasesTree/specialItems.ts create mode 100644 src/features/instance/databases/components/DatabasesTree/useDatabaseTreeViewState.ts create mode 100644 src/features/instance/databases/components/TableContextMenuItems.tsx create mode 100644 src/features/instance/databases/components/TableRowContextMenu.tsx create mode 100644 src/features/instance/databases/hooks/useExportTableCsv.ts diff --git a/src/features/instance/applications/components/ApplicationsSidebar/FileTreeContextMenu.tsx b/src/features/instance/applications/components/ApplicationsSidebar/FileTreeContextMenu.tsx index ed35f6c15..3f4cb85d4 100644 --- a/src/features/instance/applications/components/ApplicationsSidebar/FileTreeContextMenu.tsx +++ b/src/features/instance/applications/components/ApplicationsSidebar/FileTreeContextMenu.tsx @@ -62,6 +62,9 @@ export function FileTreeContextMenu({ }) { const { setOpenedEntry, setFocusedItem, setSelectedItems, selectedItems } = useEditorView(); const [target, setTarget] = useState(undefined); + // Bumped to the cursor position on every right-click so the content remounts and re-anchors there + // (see onContextMenu) -- without it Radix leaves an already-open menu at its first position. + const [anchorKey, setAnchorKey] = useState(''); const { canRename, canAddEntries, canDeleteEntry, canDownload, canRedeploy } = useEntryActions(target?.entry); const focusTarget = useCallback((next: ContextTarget) => { @@ -105,6 +108,10 @@ export function FileTreeContextMenu({ // multi-delete still spans every selected row); otherwise target just this one. const next: ContextTarget = { entry, id, selection: selectedItems.includes(id) ? selectedItems : [id] }; setTarget(next); + // Radix keeps an already-open context menu anchored to its first position (its exit animation + // prevents a re-measure on reopen), so remount the content -- keyed by the cursor point -- to + // force it to re-anchor where you actually right-clicked. + setAnchorKey(`${event.clientX},${event.clientY}`); }, [items, selectedItems]); // Re-assert the target as the action fires. This runs in the same React batch @@ -139,7 +146,11 @@ export function FileTreeContextMenu({ {target && ( - event.preventDefault()}> + event.preventDefault()} + > copy(target.entry.name)}> Copy Name diff --git a/src/features/instance/applications/components/ApplicationsSidebar/ItemArrow.tsx b/src/features/instance/applications/components/ApplicationsSidebar/ItemArrow.tsx index 1aef65bee..5e6b23fbb 100644 --- a/src/features/instance/applications/components/ApplicationsSidebar/ItemArrow.tsx +++ b/src/features/instance/applications/components/ApplicationsSidebar/ItemArrow.tsx @@ -1,5 +1,3 @@ -import type { DirectoryEntry } from '@/features/instance/applications/context/directoryEntry'; -import type { FileEntry } from '@/features/instance/applications/context/fileEntry'; import { cn } from '@/lib/cn'; import type { TreeItem } from 'react-complex-tree'; import type { TreeItemRenderContext } from 'react-complex-tree/src/types'; @@ -22,7 +20,9 @@ import type { TreeItemRenderContext } from 'react-complex-tree/src/types'; * class) so the visuals are unchanged. The enlarged hit area lives in CSS. */ export function ItemArrow({ item, context }: { - item: TreeItem; + // Data-agnostic on purpose: reads only `item.isFolder`, so both the applications and databases + // trees reuse it. (Typed as the library default `TreeItem` = `TreeItem`.) + item: TreeItem; context: TreeItemRenderContext; }) { if (!item.isFolder) { diff --git a/src/features/instance/databases/components/DatabaseActionModals.tsx b/src/features/instance/databases/components/DatabaseActionModals.tsx new file mode 100644 index 000000000..6bf1009ff --- /dev/null +++ b/src/features/instance/databases/components/DatabaseActionModals.tsx @@ -0,0 +1,104 @@ +import { useInstanceClientIdParams } from '@/config/useInstanceClient'; +import { AddTableRowModal } from '@/features/instance/databases/modals/AddTableRowModal'; +import { CreateNewTableModal } from '@/features/instance/databases/modals/CreateNewTableModal'; +import { DeleteDatabaseModal } from '@/features/instance/databases/modals/DeleteDatabaseModal'; +import { DeleteTableModal } from '@/features/instance/databases/modals/DeleteTableModal'; +import { ImportDataModal } from '@/features/instance/databases/modals/ImportDataModal'; +import { InstanceDatabaseMap } from '@/integrations/api/api.patch'; +import { setWatchedValue, useWatchedValue } from '@/lib/events/watcher'; +import { buildAbsoluteLinkToDatabasePage } from '@/lib/urls/buildAbsoluteLinkToDatabasePage'; +import { useQueryClient } from '@tanstack/react-query'; +import { useNavigate, useParams, useRouter } from '@tanstack/react-router'; +import { useCallback } from 'react'; + +/** + * Single, always-mounted home for every database/table action modal. The tree context menu and the + * right-pane toolbar both fire the same target-carrying watched values (see `watchedValueKeys.ts`), so + * an action can target ANY database/table -- not just the currently-open one -- and there is exactly + * one instance of each modal. Mounted unconditionally by the Databases page (once the map loads) so + * Drop Database / Create Table work even when no table is open or a database is empty. + */ +export function DatabaseActionModals({ instanceDatabaseMap }: { instanceDatabaseMap?: InstanceDatabaseMap }) { + const params: { + clusterId?: string; + instanceId?: string; + organizationId?: string; + databaseName?: string; + tableName?: string; + } = useParams({ strict: false }); + const navigate = useNavigate(); + const router = useRouter(); + const queryClient = useQueryClient(); + const instanceParams = useInstanceClientIdParams(); + + const goToTable = useCallback((databaseName?: string, tableName?: string) => { + void navigate({ to: buildAbsoluteLinkToDatabasePage({ ...params, databaseName, tableName }) }); + }, [navigate, params]); + + const { value: createTarget } = useWatchedValue('ShowCreateTable', false); + const { value: addTarget } = useWatchedValue('ShowAddTableRecords', false); + const { value: importTarget } = useWatchedValue('ShowImportData', false); + + const addInstanceTable = addTarget + ? instanceDatabaseMap?.[addTarget.databaseName]?.[addTarget.tableName] + : undefined; + + const onImported = useCallback(async (databaseName: string, tableName: string) => { + // The import may have created a new table (or even database), so refresh the tree too. + await queryClient.invalidateQueries({ + queryKey: [instanceParams.entityId, 'describe_all'], + refetchType: 'all', + }); + goToTable(databaseName, tableName); + await router.invalidate(); + }, [queryClient, instanceParams.entityId, goToTable, router]); + + // After a drop, only navigate when the dropped entity is the one currently open -- dropping some + // other tree item just needs the `describe_all` invalidation (done by the modal) to rebuild the tree. + const onTableDropped = useCallback(({ databaseName, tableName }: { databaseName: string; tableName: string }) => { + if (params.databaseName === databaseName && params.tableName === tableName) { + goToTable(databaseName, undefined); // fall back to the database overview + } + }, [params.databaseName, params.tableName, goToTable]); + const onDatabaseDropped = useCallback(({ databaseName }: { databaseName: string }) => { + if (params.databaseName === databaseName) { + goToTable(undefined, undefined); // the page re-picks the first remaining database + } + }, [params.databaseName, goToTable]); + + return ( + <> + setWatchedValue('ShowCreateTable', open ? (createTarget || {}) : false)} + databaseName={createTarget ? createTarget.databaseName : undefined} + onCreated={goToTable} + /> + {addTarget && addInstanceTable && ( + { + if (!open) { + setWatchedValue('ShowAddTableRecords', false); + } + }} + refreshTable={() => + void queryClient.invalidateQueries({ + queryKey: [instanceParams.entityId, addTarget.databaseName, addTarget.tableName], + })} + /> + )} + setWatchedValue('ShowImportData', open ? (importTarget || {}) : false)} + instanceDatabaseMap={instanceDatabaseMap} + databaseName={importTarget ? importTarget.databaseName : undefined} + tableName={importTarget ? importTarget.tableName : undefined} + onImported={onImported} + /> + + + + ); +} diff --git a/src/features/instance/databases/components/DatabaseOverview.tsx b/src/features/instance/databases/components/DatabaseOverview.tsx new file mode 100644 index 000000000..c2e658ff4 --- /dev/null +++ b/src/features/instance/databases/components/DatabaseOverview.tsx @@ -0,0 +1,165 @@ +import { Button } from '@/components/ui/button'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdownMenu'; +import { useInstanceClientIdParams } from '@/config/useInstanceClient'; +import { TableRowContextMenu } from '@/features/instance/databases/components/TableRowContextMenu'; +import { formatBytes } from '@/features/instance/status/analytics/lib/time'; +import { useInstanceBrowseManagePermission } from '@/hooks/usePermissions'; +import { InstanceDatabaseMap } from '@/integrations/api/api.patch'; +import { getDescribeAllQueryOptions } from '@/integrations/api/instance/database/getDescribeAll'; +import { setWatchedValue } from '@/lib/events/watcher'; +import { buildAbsoluteLinkToDatabasePage } from '@/lib/urls/buildAbsoluteLinkToDatabasePage'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate, useParams } from '@tanstack/react-router'; +import { CloudUploadIcon, EllipsisIcon, PlusIcon, Trash2Icon } from 'lucide-react'; +import { useCallback } from 'react'; + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function DatabaseOverview({ instanceDatabaseMap, databaseName }: { + instanceDatabaseMap?: InstanceDatabaseMap; + databaseName: string; +}) { + const params: { + clusterId?: string; + instanceId?: string; + organizationId?: string; + } = useParams({ strict: false }); + const navigate = useNavigate(); + const instanceParams = useInstanceClientIdParams(); + const canManage = useInstanceBrowseManagePermission(); + + const tables = instanceDatabaseMap?.[databaseName] ?? {}; + const tableNames = Object.keys(tables).sort(); + + // Sizes are already in the fast (count-skipping) map. Record counts are not, so fetch describe_all + // WITH counts separately (distinct cache key) and backfill them as they arrive. + const { data: countedMap } = useQuery(getDescribeAllQueryOptions({ ...instanceParams })); + const countedTables = countedMap?.[databaseName]; + + // Every table in a database shares the same underlying store, so db_size/db_audit_size are the same + // on each -- read them off any table. + const anyTable = tableNames.length ? tables[tableNames[0]] : undefined; + const dbSize = anyTable?.db_size ?? 0; + const auditSize = anyTable?.db_audit_size ?? 0; + const totalRecords = countedTables + ? Object.values(countedTables).reduce((sum, table) => sum + (table.record_count ?? 0), 0) + : undefined; + + const goToTable = useCallback((tableName: string) => { + void navigate({ to: buildAbsoluteLinkToDatabasePage({ ...params, databaseName, tableName }) }); + }, [navigate, params, databaseName]); + + return ( +
+
+
+

{databaseName}

+
+ {canManage && ( +
+ + + + + + + + setWatchedValue('ShowDeleteDatabase', { databaseName })} + > + + Drop Database + + + +
+ )} +
+ +
+ + + + +
+ + {tableNames.length === 0 + ? ( +
+

This database has no tables yet.

+ {canManage &&

Use “Create a Table” above, or right-click the database in the sidebar.

} +
+ ) + : ( + +
+ + + + + + + + + + + + + {tableNames.map((tableName) => { + const table = tables[tableName]; + const counted = countedTables?.[tableName]; + const primaryKey = table.primary_key ?? table.hash_attribute ?? '—'; + const recordCount = counted?.record_count; + const recordLabel = recordCount === undefined + ? '…' + : `${counted?.estimated_record_range ? '~' : ''}${recordCount.toLocaleString()}`; + return ( + goToTable(tableName)} + className="border-b border-border dark:border-grey-700 last:border-0 cursor-pointer hover:bg-accent dark:hover:bg-grey-700/80" + > + + + + + + + + ); + })} + +
TableRecordsSizeColumnsPrimary KeyLast Updated
{tableName}{recordLabel}{formatBytes(table.table_size ?? 0)}{table.attributes.length.toLocaleString()}{primaryKey} + {table.last_updated_record + ? new Date(table.last_updated_record).toLocaleString() + : '—'} +
+
+
+ )} +
+ ); +} diff --git a/src/features/instance/databases/components/DatabaseTableView.tsx b/src/features/instance/databases/components/DatabaseTableView.tsx index 68392a34d..caaf0cef2 100644 --- a/src/features/instance/databases/components/DatabaseTableView.tsx +++ b/src/features/instance/databases/components/DatabaseTableView.tsx @@ -2,11 +2,8 @@ import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdownMenu'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { formatBrowseDataTableHeader } from '@/features/instance/databases/functions/formatBrowseDataTableHeader'; -import { AddTableRowModal } from '@/features/instance/databases/modals/AddTableRowModal'; -import { DeleteDatabaseModal } from '@/features/instance/databases/modals/DeleteDatabaseModal'; -import { DeleteTableModal } from '@/features/instance/databases/modals/DeleteTableModal'; +import { useExportTableCsv } from '@/features/instance/databases/hooks/useExportTableCsv'; import { EditTableRowModal } from '@/features/instance/databases/modals/EditTableRowModal'; -import { ImportDataModal } from '@/features/instance/databases/modals/ImportDataModal'; import { useAdminMode } from '@/hooks/useAuth'; import { useEffectedState } from '@/hooks/useEffectedState'; import { useInstanceBrowseManagePermission, useInstanceSchemaTablePermission } from '@/hooks/usePermissions'; @@ -18,22 +15,20 @@ import { useCleanupOrphanBlobsMutation } from '@/integrations/api/instance/datab import { useDeleteTableRecords } from '@/integrations/api/instance/database/deleteTableRecords'; import { getDescribeTableQueryOptions } from '@/integrations/api/instance/database/getDescribeTable'; import { - getSearchByConditions, getSearchByConditionsOptions, SearchCondition, translateColumnFilterToSearchConditions, } from '@/integrations/api/instance/database/getSearchByConditions'; import { getSearchByIdOptions } from '@/integrations/api/instance/database/getSearchById'; -import { getSearchByValue, getSearchByValueOptions } from '@/integrations/api/instance/database/getSearchByValue'; +import { getSearchByValueOptions } from '@/integrations/api/instance/database/getSearchByValue'; import { getTableRecordCountQueryOptions } from '@/integrations/api/instance/database/getTableRecordCount'; import { useUpdateTableRecords } from '@/integrations/api/instance/database/updateTableRecords'; -import { useSetWatchedValue } from '@/lib/events/watcher'; +import { setWatchedValue } from '@/lib/events/watcher'; import { keyBy } from '@/lib/keyBy'; import { onClickStopPropagation } from '@/lib/onClickStopPropagation'; -import { buildAbsoluteLinkToDatabasePage } from '@/lib/urls/buildAbsoluteLinkToDatabasePage'; import { zodResolver } from '@hookform/resolvers/zod'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Link, useNavigate, useParams } from '@tanstack/react-router'; +import { Link, useParams } from '@tanstack/react-router'; import { Row, VisibilityState } from '@tanstack/react-table'; import { BrushCleaningIcon, @@ -68,7 +63,6 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName instanceId?: string; } = useParams({ strict: false }); - const navigate = useNavigate(); const instanceParams = useInstanceClientIdParams(); const { clusterId, instanceId } = allParams; @@ -145,8 +139,6 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName }, [allParams, clearFilters]); const { dataTableColumns, primaryKey } = formatBrowseDataTableHeader(instanceTable); - const [isAddModalOpen, setIsAddModalOpen] = useState(false); - const [isImportDataModalOpen, setIsImportDataModalOpen] = useState(false); const [sort, setSort] = useEffectedState( { attribute: primaryKey, @@ -259,36 +251,18 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName }); }, [cleanupOrphanBlobs, databaseName, instanceParams, refreshTable]); - const [isExportingCSV, setisExportingCSV] = useState(false); - const onExportCSVClicked = useCallback(async () => { - if (!primaryKey) { - return; - } - const id = toast.loading('Loading CSV...'); - setisExportingCSV(true); - const allResultsAsCSV = { - pageIndex: 0, - pageSize: 1_000_000, - headers: { - Accept: 'text/csv', - }, - }; - const response = await ( - useFilteredList - ? getSearchByConditions({ ...searchByConditionsParams, ...allResultsAsCSV }) - : getSearchByValue({ ...searchByValueParams, ...allResultsAsCSV }) - ); - toast.loading('Preparing CSV...', { id }); - const content = response.data as unknown as string; - const blob = new Blob([content], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const downloadLink = document.createElement('a'); - downloadLink.href = url; - downloadLink.setAttribute('download', `${databaseName}.${tableName}.${new Date().toISOString()}.csv`); - downloadLink.click(); - toast.success('CSV Exported!', { id }); - setisExportingCSV(false); - }, [databaseName, tableName, searchByValueOptions, searchByConditionsOptions]); + const { exportCsv, isExporting: isExportingCSV } = useExportTableCsv(); + const onExportCSVClicked = useCallback(() => { + // Preserve the filter-aware export: the toolbar exports what's currently on screen (active + // filters + sort). The tree context menu exports the whole table via the same hook. + void exportCsv({ + databaseName, + tableName, + primaryKey, + sort, + conditions: useFilteredList ? appliedSearchConditions : null, + }); + }, [exportCsv, databaseName, tableName, primaryKey, sort, useFilteredList, appliedSearchConditions]); const onRecordUpdate = useCallback((data: Record[]) => { updateTableRecords( @@ -326,25 +300,6 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName ); }, [deleteTableRecords, instanceParams, databaseName, tableName, refreshTable]); - const onImported = useCallback(async (importedDatabase: string, importedTable: string) => { - // The import may have created a new table (or even database), so refresh the tree too. - await queryClient.invalidateQueries({ - queryKey: [instanceParams.entityId, 'describe_all'], - refetchType: 'all', - }); - if (importedDatabase === databaseName && importedTable === tableName) { - void refreshTable(); - } else { - void navigate({ - to: buildAbsoluteLinkToDatabasePage({ - ...allParams, - databaseName: importedDatabase, - tableName: importedTable, - }), - }); - } - }, [queryClient, instanceParams.entityId, databaseName, tableName, refreshTable, navigate, allParams]); - const onRowClick = (rowData: Row>) => { setSelectedIds([rowData.original[primaryKey]]); setIsEditModalOpen(!isEditModalOpen); @@ -358,26 +313,18 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName const onRefreshClick = useRefreshClick(refreshTable); const onAddClicked = useCallback(() => { - setIsAddModalOpen(true); - }, [setIsAddModalOpen]); + setWatchedValue('ShowAddTableRecords', { databaseName, tableName }); + }, [databaseName, tableName]); const onImportDataClicked = useCallback(() => { - setIsImportDataModalOpen(true); - }, [setIsImportDataModalOpen]); - - const onDeleted = useCallback( - (deleted: 'table' | 'database') => void navigate({ to: deleted === 'table' ? '../' : '../../' }), - [navigate], - ); + setWatchedValue('ShowImportData', { databaseName, tableName }); + }, [databaseName, tableName]); const [columnVisibility, setColumnVisibility] = useSessionStorage( `ColumnDisplayed/${databaseName}/${tableName}` as 'ColumnDisplayed/{database}/{table}', {} satisfies VisibilityState, ); - const openDeleteTable = useSetWatchedValue('ShowDeleteTable', true); - const openDeleteDatabase = useSetWatchedValue('ShowDeleteDatabase', true); - return ( <>
@@ -386,7 +333,6 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName - - ))} - - - )} - {canManageBrowseInstance && ( -
- -
- -
- -
- )}
); } diff --git a/src/features/instance/databases/components/DatabasesTree/DatabaseTreeContextMenu.tsx b/src/features/instance/databases/components/DatabasesTree/DatabaseTreeContextMenu.tsx new file mode 100644 index 000000000..7341d1d5b --- /dev/null +++ b/src/features/instance/databases/components/DatabasesTree/DatabaseTreeContextMenu.tsx @@ -0,0 +1,92 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/components/ui/contextMenu'; +import { TableContextMenuItems } from '@/features/instance/databases/components/TableContextMenuItems'; +import { useInstanceBrowseManagePermission } from '@/hooks/usePermissions'; +import { InstanceDatabaseMap } from '@/integrations/api/api.patch'; +import { setWatchedValue } from '@/lib/events/watcher'; +import { PlusIcon, Trash2Icon } from 'lucide-react'; +import { ReactNode, useCallback, useState } from 'react'; +import type { TreeItem } from 'react-complex-tree'; +import type { DbTreeData } from './buildItems'; + +/** + * Right-click menu for the databases tree. Resolves the clicked row from `data-rct-item-id`, remembers + * it as the target, and fires the shared target-carrying watched values (reusing the same modals the + * toolbar uses). Table rows share their action list with the overview via `TableContextMenuItems`. + * `modal={false}` is load-bearing -- every action opens a Dialog, and a modal menu would leave + * `pointer-events: none` stuck on , freezing the page. + */ +export function DatabaseTreeContextMenu({ items, instanceDatabaseMap, children }: { + items: Record>; + instanceDatabaseMap?: InstanceDatabaseMap; + children: ReactNode; +}) { + const canManage = useInstanceBrowseManagePermission(); + const [target, setTarget] = useState(undefined); + // Bumped to the cursor position on every right-click so the content remounts and re-anchors there + // -- without it Radix leaves an already-open menu at its first position. + const [anchorKey, setAnchorKey] = useState(''); + + const onContextMenu = useCallback((event: React.MouseEvent) => { + const id = (event.target as HTMLElement).closest('[data-rct-item-id]')?.getAttribute('data-rct-item-id'); + const data = id ? items[id]?.data : undefined; + // Suppress on empty space / the synthetic root + create-table rows. Clearing the target keeps a + // stale menu from flashing if the open-on-right-click still fires with nothing to show. + if (!data || data.kind === 'root' || data.kind === 'createTable') { + setTarget(undefined); + event.preventDefault(); + return; + } + setTarget(data); + setAnchorKey(`${event.clientX},${event.clientY}`); + }, [items]); + + return ( + + +
+ {children} +
+
+ {target && ( + event.preventDefault()} + > + {target.kind === 'database' && canManage && ( + <> + setWatchedValue('ShowCreateTable', { databaseName: target.databaseName })} + > + + Create a Table + + + setWatchedValue('ShowDeleteDatabase', { databaseName: target.databaseName })} + > + + Drop Database + + + )} + + {target.kind === 'table' && ( + + )} + + )} +
+ ); +} diff --git a/src/features/instance/databases/components/DatabasesTree/ItemTitle.tsx b/src/features/instance/databases/components/DatabasesTree/ItemTitle.tsx new file mode 100644 index 000000000..790c213c6 --- /dev/null +++ b/src/features/instance/databases/components/DatabasesTree/ItemTitle.tsx @@ -0,0 +1,28 @@ +import { cn } from '@/lib/cn'; +import { DatabaseIcon, PlusIcon, Table2Icon } from 'lucide-react'; +import type { TreeItem } from 'react-complex-tree'; +import type { DbTreeData } from './buildItems'; + +const iconClassName = 'mr-1.5 h-4 w-4 shrink-0 pointer-events-none'; + +export function ItemTitle({ title, item }: { + title: string; + item: TreeItem; +}) { + const kind = item.data.kind; + return ( + <> + { + /* Palette matches the applications sidebar: green "+" (New Application), orange containers + (folders/databases), and a neutral leaf icon that inherits the row's text color -- so the + two trees read as one system and stay high-contrast in both light and dark mode. */ + } + {kind === 'createTable' + ? + : kind === 'database' + ? + : } + {title} + + ); +} diff --git a/src/features/instance/databases/components/DatabasesTree/buildItems.test.ts b/src/features/instance/databases/components/DatabasesTree/buildItems.test.ts new file mode 100644 index 000000000..bedcf4798 --- /dev/null +++ b/src/features/instance/databases/components/DatabasesTree/buildItems.test.ts @@ -0,0 +1,67 @@ +import { InstanceDatabaseMap, InstanceTable } from '@/integrations/api/api.patch'; +import { describe, expect, it } from 'vitest'; +import { buildItems, tableItemId } from './buildItems'; +import { getItemTitle } from './getItemTitle'; +import { createTableId, rootId } from './specialItems'; + +function table(schema: string, name: string): InstanceTable { + return { + schema, + name, + audit: false, + schema_defined: true, + db_size: 0, + sources: [], + table_size: 0, + db_audit_size: 0, + attributes: [], + }; +} + +// One database with two tables (deliberately unsorted), a second database that shares a table NAME +// (`shared`) with the first, and an empty database. +const map: InstanceDatabaseMap = { + beta: { shared: table('beta', 'shared') }, + alpha: { widgets: table('alpha', 'widgets'), shared: table('alpha', 'shared') }, + empty: {}, +}; + +describe('buildItems', () => { + it('sorts databases and tables, and mounts them under the synthetic root', () => { + const { items, rootId: root } = buildItems(map, { canManage: true }); + expect(root).toBe(rootId); + // createTable row first, then databases alphabetically. + expect(items[rootId].children).toEqual([createTableId, 'alpha', 'beta', 'empty']); + expect(items['alpha'].isFolder).toBe(true); + expect(items['alpha'].children).toEqual([tableItemId('alpha', 'shared'), tableItemId('alpha', 'widgets')]); + expect(items['empty'].children).toEqual([]); + }); + + it('gives tables composite ids so duplicate names across databases do not collide', () => { + const { items } = buildItems(map, { canManage: true }); + expect(items['alpha/shared'].data).toEqual({ kind: 'table', databaseName: 'alpha', tableName: 'shared' }); + expect(items['beta/shared'].data).toEqual({ kind: 'table', databaseName: 'beta', tableName: 'shared' }); + expect(items['alpha/shared'].isFolder).toBe(false); + }); + + it('omits the create-table row when the user cannot manage the instance', () => { + const { items } = buildItems(map, { canManage: false }); + expect(items[rootId].children).toEqual(['alpha', 'beta', 'empty']); + // The item itself still exists (harmless), but it is not reachable from the root. + expect(items[rootId].children).not.toContain(createTableId); + }); + + it('handles an undefined map as an empty tree', () => { + const { items } = buildItems(undefined, { canManage: true }); + expect(items[rootId].children).toEqual([createTableId]); + }); +}); + +describe('getItemTitle', () => { + it('titles each kind of row', () => { + const { items } = buildItems(map, { canManage: true }); + expect(getItemTitle(items[createTableId])).toBe('Create a Table'); + expect(getItemTitle(items['alpha'])).toBe('alpha'); + expect(getItemTitle(items['alpha/widgets'])).toBe('widgets'); + }); +}); diff --git a/src/features/instance/databases/components/DatabasesTree/buildItems.ts b/src/features/instance/databases/components/DatabasesTree/buildItems.ts new file mode 100644 index 000000000..64576655e --- /dev/null +++ b/src/features/instance/databases/components/DatabasesTree/buildItems.ts @@ -0,0 +1,73 @@ +import { InstanceDatabaseMap } from '@/integrations/api/api.patch'; +import type { TreeItem } from 'react-complex-tree'; +import { createTableId, rootId } from './specialItems'; + +/** Discriminated union carried as each tree item's `data`. */ +export type DbTreeData = + | { kind: 'root' } + | { kind: 'createTable' } + | { kind: 'database'; databaseName: string } + | { kind: 'table'; databaseName: string; tableName: string }; + +/** + * Composite id for a table row. A table name can repeat across databases, so the tree index must be + * unique per (database, table). `/` is safe: `schemaRegex` forbids it in database/table names, and the + * URL already joins `db/table` with `/`. + */ +export function tableItemId(databaseName: string, tableName: string): string { + return `${databaseName}/${tableName}`; +} + +/** + * Convert the `describe_all` map into the flat `Record` react-complex-tree consumes: + * a synthetic root whose children are an optional "Create a Table" row (management only) followed by + * each database (a folder of its tables). + */ +export function buildItems( + instanceDatabaseMap: InstanceDatabaseMap | undefined, + { canManage }: { canManage: boolean }, +): { items: Record>; rootId: string } { + const items: Record> = {}; + const databaseNames = Object.keys(instanceDatabaseMap || {}).sort(); + + for (const databaseName of databaseNames) { + const tableNames = Object.keys(instanceDatabaseMap?.[databaseName] || {}).sort(); + items[databaseName] = { + index: databaseName, + isFolder: true, + children: tableNames.map(tableName => tableItemId(databaseName, tableName)), + data: { kind: 'database', databaseName }, + canMove: false, + canRename: false, + }; + for (const tableName of tableNames) { + const id = tableItemId(databaseName, tableName); + items[id] = { + index: id, + isFolder: false, + data: { kind: 'table', databaseName, tableName }, + canMove: false, + canRename: false, + }; + } + } + + items[createTableId] = { + index: createTableId, + isFolder: false, + data: { kind: 'createTable' }, + canMove: false, + canRename: false, + }; + + items[rootId] = { + index: rootId, + isFolder: true, + children: [...(canManage ? [createTableId] : []), ...databaseNames], + data: { kind: 'root' }, + canMove: false, + canRename: false, + }; + + return { items, rootId }; +} diff --git a/src/features/instance/databases/components/DatabasesTree/getItemTitle.ts b/src/features/instance/databases/components/DatabasesTree/getItemTitle.ts new file mode 100644 index 000000000..22b59fb37 --- /dev/null +++ b/src/features/instance/databases/components/DatabasesTree/getItemTitle.ts @@ -0,0 +1,15 @@ +import type { TreeItem } from 'react-complex-tree'; +import type { DbTreeData } from './buildItems'; + +export function getItemTitle(item: TreeItem): string { + switch (item.data.kind) { + case 'createTable': + return 'Create a Table'; + case 'database': + return item.data.databaseName; + case 'table': + return item.data.tableName; + default: + return ''; + } +} diff --git a/src/features/instance/databases/components/DatabasesTree/index.tsx b/src/features/instance/databases/components/DatabasesTree/index.tsx new file mode 100644 index 000000000..1554ae627 --- /dev/null +++ b/src/features/instance/databases/components/DatabasesTree/index.tsx @@ -0,0 +1,95 @@ +import { ItemArrow } from '@/features/instance/applications/components/ApplicationsSidebar/ItemArrow'; +import '@/features/instance/applications/components/ApplicationsSidebar/file-explorer-modern.css'; +import { useInstanceBrowseManagePermission } from '@/hooks/usePermissions'; +import { InstanceDatabaseMap } from '@/integrations/api/api.patch'; +import { setWatchedValue } from '@/lib/events/watcher'; +import { buildAbsoluteLinkToDatabasePage } from '@/lib/urls/buildAbsoluteLinkToDatabasePage'; +import { useNavigate, useParams } from '@tanstack/react-router'; +import { CSSProperties, useCallback, useMemo } from 'react'; +import { ControlledTreeEnvironment, InteractionMode, Tree, TreeItemIndex } from 'react-complex-tree'; +import { buildItems, DbTreeData } from './buildItems'; +import { DatabaseTreeContextMenu } from './DatabaseTreeContextMenu'; +import { getItemTitle } from './getItemTitle'; +import { ItemTitle } from './ItemTitle'; +import { useDatabaseTreeViewState } from './useDatabaseTreeViewState'; + +// The shared `.app-tree-scroll` styling insets the scrollbar for the applications drop target; the +// databases tree has none, so zero it out. +const scrollStyle = { '--rct-drop-target-height': '0px' } as CSSProperties; + +export function DatabasesTree({ instanceDatabaseMap }: { instanceDatabaseMap?: InstanceDatabaseMap }) { + const params: { + clusterId?: string; + instanceId?: string; + organizationId?: string; + databaseName?: string; + tableName?: string; + } = useParams({ strict: false }); + const navigate = useNavigate(); + const canManage = useInstanceBrowseManagePermission(); + + const { items, rootId } = useMemo( + () => buildItems(instanceDatabaseMap, { canManage }), + [instanceDatabaseMap, canManage], + ); + const { focusedItem, setFocusedItem, expandedItems, setExpandedItems, selectedItems } = useDatabaseTreeViewState( + params, + ); + + const goTo = useCallback((databaseName?: string, tableName?: string) => { + void navigate({ to: buildAbsoluteLinkToDatabasePage({ ...params, databaseName, tableName }) }); + }, [navigate, params]); + + // Single-click "selects" a row (see DoubleClickItemToExpandInteractionManager): a database navigates + // to its overview, a table to its data grid, and the synthetic row opens the create-table modal. + const onActivateItem = useCallback((data: DbTreeData) => { + switch (data.kind) { + case 'createTable': + setWatchedValue('ShowCreateTable', { databaseName: params.databaseName }); + break; + case 'database': + if (params.databaseName !== data.databaseName || params.tableName) { + goTo(data.databaseName, undefined); + } + break; + case 'table': + if (params.databaseName !== data.databaseName || params.tableName !== data.tableName) { + goTo(data.databaseName, data.tableName); + } + break; + } + }, [params.databaseName, params.tableName, goTo]); + + const onSelectItems = useCallback((selected: TreeItemIndex[]) => { + const id = selected[selected.length - 1]; + const data = id !== undefined ? items[id]?.data : undefined; + if (data) { + onActivateItem(data); + } + }, [items, onActivateItem]); + + return ( +
+ + setFocusedItem(item.index)} + onExpandItem={item => setExpandedItems(prev => (prev.includes(item.index) ? prev : [...prev, item.index]))} + onCollapseItem={item => setExpandedItems(prev => prev.filter(index => index !== item.index))} + onSelectItems={onSelectItems} + > + + + +
+ ); +} diff --git a/src/features/instance/databases/components/DatabasesTree/specialItems.ts b/src/features/instance/databases/components/DatabasesTree/specialItems.ts new file mode 100644 index 000000000..9537a6af4 --- /dev/null +++ b/src/features/instance/databases/components/DatabasesTree/specialItems.ts @@ -0,0 +1,2 @@ +export const rootId = '__db_root__'; +export const createTableId = '__create_table__'; diff --git a/src/features/instance/databases/components/DatabasesTree/useDatabaseTreeViewState.ts b/src/features/instance/databases/components/DatabasesTree/useDatabaseTreeViewState.ts new file mode 100644 index 000000000..26b8bf105 --- /dev/null +++ b/src/features/instance/databases/components/DatabasesTree/useDatabaseTreeViewState.ts @@ -0,0 +1,48 @@ +import { useInstanceClientIdParams } from '@/config/useInstanceClient'; +import { useSessionStorage } from '@/hooks/useSessionStorage'; +import { useEffect, useMemo, useState } from 'react'; +import type { TreeItemIndex } from 'react-complex-tree'; +import { tableItemId } from './buildItems'; + +/** + * Owns the controlled tree view state (focus / expansion / selection). Selection is derived from the + * route (the open database/table is the selected row); expansion is persisted per instance in session + * storage. Navigation itself lives in the tree component -- this hook only tracks state. + */ +export function useDatabaseTreeViewState({ databaseName, tableName }: { + databaseName?: string; + tableName?: string; +}) { + const { entityId } = useInstanceClientIdParams(); + + const selectedItems = useMemo(() => { + if (databaseName && tableName) { + return [tableItemId(databaseName, tableName)]; + } + if (databaseName) { + return [databaseName]; + } + return []; + }, [databaseName, tableName]); + + const [focusedItem, setFocusedItem] = useState(selectedItems[0]); + // Re-sync keyboard focus to the open row whenever the route changes (deep link, nav from elsewhere). + useEffect(() => { + if (selectedItems[0] !== undefined) { + setFocusedItem(selectedItems[0]); + } + }, [selectedItems]); + + const [expandedItems, setExpandedItems] = useSessionStorage( + `DatabaseTreeExpanded/${entityId}` as 'DatabaseTreeExpanded/{entityId}', + (databaseName ? [databaseName] : []) as TreeItemIndex[], + ); + // The active database must always be expanded (e.g. deep-linking into a collapsed one). + useEffect(() => { + if (databaseName) { + setExpandedItems(prev => (prev.includes(databaseName) ? prev : [...prev, databaseName])); + } + }, [databaseName, setExpandedItems]); + + return { focusedItem, setFocusedItem, expandedItems, setExpandedItems, selectedItems }; +} diff --git a/src/features/instance/databases/components/TableContextMenuItems.tsx b/src/features/instance/databases/components/TableContextMenuItems.tsx new file mode 100644 index 000000000..4fe6dcd8f --- /dev/null +++ b/src/features/instance/databases/components/TableContextMenuItems.tsx @@ -0,0 +1,65 @@ +import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/contextMenu'; +import { formatBrowseDataTableHeader } from '@/features/instance/databases/functions/formatBrowseDataTableHeader'; +import { useExportTableCsv } from '@/features/instance/databases/hooks/useExportTableCsv'; +import { useInstanceBrowseManagePermission, useInstanceSchemaTablePermission } from '@/hooks/usePermissions'; +import { InstanceDatabaseMap } from '@/integrations/api/api.patch'; +import { setWatchedValue } from '@/lib/events/watcher'; +import { useParams } from '@tanstack/react-router'; +import { CloudDownloadIcon, CloudUploadIcon, PlusIcon, TrashIcon } from 'lucide-react'; + +/** + * The per-table right-click actions (Add Records / Import / Export CSV / Drop Table), shared by the + * sidebar tree and the database overview's table list so both menus stay identical. Fires the same + * target-carrying watched values the toolbar uses; Export runs the shared CSV hook directly. + * Renders only `ContextMenuItem`s, so it must live inside a `ContextMenuContent`. + */ +export function TableContextMenuItems({ databaseName, tableName, instanceDatabaseMap }: { + databaseName: string; + tableName: string; + instanceDatabaseMap?: InstanceDatabaseMap; +}) { + const { clusterId, instanceId }: { clusterId?: string; instanceId?: string } = useParams({ strict: false }); + const canManage = useInstanceBrowseManagePermission(); + const canInsert = useInstanceSchemaTablePermission(instanceId ?? clusterId, databaseName, tableName, 'insert'); + const { exportCsv } = useExportTableCsv(); + + const isLastTable = Object.keys(instanceDatabaseMap?.[databaseName] || {}).length <= 1; + + const onExport = () => { + const { primaryKey } = formatBrowseDataTableHeader(instanceDatabaseMap?.[databaseName]?.[tableName]); + void exportCsv({ databaseName, tableName, primaryKey, conditions: null }); + }; + + return ( + <> + {canInsert && ( + setWatchedValue('ShowAddTableRecords', { databaseName, tableName })}> + + Add New Record(s) + + )} + {canInsert && ( + setWatchedValue('ShowImportData', { databaseName, tableName })}> + + Import Data + + )} + + + Export CSV + + {canManage && !isLastTable && ( + <> + + setWatchedValue('ShowDeleteTable', { databaseName, tableName })} + > + + Drop Table + + + )} + + ); +} diff --git a/src/features/instance/databases/components/TableRowContextMenu.tsx b/src/features/instance/databases/components/TableRowContextMenu.tsx new file mode 100644 index 000000000..69ddf5488 --- /dev/null +++ b/src/features/instance/databases/components/TableRowContextMenu.tsx @@ -0,0 +1,55 @@ +import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/contextMenu'; +import { TableContextMenuItems } from '@/features/instance/databases/components/TableContextMenuItems'; +import { InstanceDatabaseMap } from '@/integrations/api/api.patch'; +import { ReactNode, useCallback, useState } from 'react'; + +/** + * Wraps the database overview's table list so right-clicking a table row opens the same actions menu + * as right-clicking that table in the sidebar tree. Resolves the clicked row from a `data-table-name` + * attribute the caller puts on each row. `modal={false}` (as in the tree menu) keeps a menu-opened + * Dialog from leaving `pointer-events: none` stuck on . + */ +export function TableRowContextMenu({ databaseName, instanceDatabaseMap, children }: { + databaseName: string; + instanceDatabaseMap?: InstanceDatabaseMap; + children: ReactNode; +}) { + const [tableName, setTableName] = useState(undefined); + // Bumped to the cursor position on every right-click so the content remounts and re-anchors there + // -- without it Radix leaves an already-open menu at its first position. + const [anchorKey, setAnchorKey] = useState(''); + + const onContextMenu = useCallback((event: React.MouseEvent) => { + const name = (event.target as HTMLElement).closest('[data-table-name]')?.getAttribute('data-table-name'); + if (!name) { + setTableName(undefined); + event.preventDefault(); + return; + } + setTableName(name); + setAnchorKey(`${event.clientX},${event.clientY}`); + }, []); + + return ( + + +
+ {children} +
+
+ {tableName && ( + event.preventDefault()} + > + + + )} +
+ ); +} diff --git a/src/features/instance/databases/hooks/useExportTableCsv.ts b/src/features/instance/databases/hooks/useExportTableCsv.ts new file mode 100644 index 000000000..33b051a36 --- /dev/null +++ b/src/features/instance/databases/hooks/useExportTableCsv.ts @@ -0,0 +1,68 @@ +import { useInstanceClientIdParams } from '@/config/useInstanceClient'; +import { getSearchByConditions, SearchCondition } from '@/integrations/api/instance/database/getSearchByConditions'; +import { getSearchByValue } from '@/integrations/api/instance/database/getSearchByValue'; +import { useCallback, useState } from 'react'; +import { toast } from 'sonner'; + +interface ExportTableCsvParams { + databaseName: string; + tableName: string; + primaryKey: string; + /** Defaults to primary-key order (natural order) when omitted. */ + sort?: { attribute: string; descending: boolean }; + /** When provided, exports the filtered result set; otherwise the whole table. */ + conditions?: SearchCondition[] | null; +} + +/** + * Shared "download this table as CSV" plumbing, lifted out of the table view so both the right-pane + * toolbar (which passes the active filters/sort) and the tree context menu (which exports the whole + * table) can trigger it. Fetches every row with an `Accept: text/csv` header and downloads the blob. + */ +export function useExportTableCsv() { + const instanceParams = useInstanceClientIdParams(); + const [isExporting, setIsExporting] = useState(false); + + const exportCsv = useCallback( + async ({ databaseName, tableName, primaryKey, sort, conditions }: ExportTableCsvParams) => { + if (!primaryKey) { + toast.error(`Cannot export "${tableName}": no primary key found.`); + return; + } + const id = toast.loading('Loading CSV...'); + setIsExporting(true); + try { + const base = { + ...instanceParams, + databaseName, + tableName, + sort: sort ?? { attribute: primaryKey, descending: false }, + pageIndex: 0, + pageSize: 1_000_000, + onlyIfCached: false, + headers: { Accept: 'text/csv' }, + }; + const response = await (conditions?.length + ? getSearchByConditions({ ...base, conditions }) + : getSearchByValue({ ...base, searchAttribute: primaryKey })); + toast.loading('Preparing CSV...', { id }); + const content = response.data as unknown as string; + const blob = new Blob([content], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const downloadLink = document.createElement('a'); + downloadLink.href = url; + downloadLink.setAttribute('download', `${databaseName}.${tableName}.${new Date().toISOString()}.csv`); + downloadLink.click(); + URL.revokeObjectURL(url); + toast.success('CSV Exported!', { id }); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to export CSV', { id }); + } finally { + setIsExporting(false); + } + }, + [instanceParams], + ); + + return { exportCsv, isExporting }; +} diff --git a/src/features/instance/databases/index.tsx b/src/features/instance/databases/index.tsx index 1d192b8c9..a51c8f814 100644 --- a/src/features/instance/databases/index.tsx +++ b/src/features/instance/databases/index.tsx @@ -3,6 +3,8 @@ import { getDescribeAllQueryOptions } from '@/integrations/api/instance/database import { buildAbsoluteLinkToDatabasePage } from '@/lib/urls/buildAbsoluteLinkToDatabasePage'; import { useQuery } from '@tanstack/react-query'; import { Navigate, useParams } from '@tanstack/react-router'; +import { DatabaseActionModals } from './components/DatabaseActionModals'; +import { DatabaseOverview } from './components/DatabaseOverview'; import { DatabasesSidebar } from './components/DatabasesSidebar'; import { DatabaseTableView } from './components/DatabaseTableView'; @@ -16,50 +18,63 @@ export function Databases() { const instanceParams = useInstanceClientIdParams(); // Skip the per-table record-count scan here: the sidebar and table view only need schema to render, - // and the selected table's count is fetched separately (see DatabaseTableView) so a slow count never - // blocks the database tree or the first page of records from appearing. + // and the selected table's count is fetched separately (see DatabaseTableView / DatabaseOverview) so + // a slow count never blocks the database tree or the first page of records from appearing. const { data: instanceDatabaseMap } = useQuery( getDescribeAllQueryOptions({ ...instanceParams, skipRecordCount: true }), ); - let newDatabaseName: string | undefined; - let newTableName: string | undefined; if (instanceDatabaseMap) { - if (!params.databaseName) { - newDatabaseName = Object.keys(instanceDatabaseMap).sort()[0]; - } - const databaseName = params.databaseName ?? newDatabaseName; - if (!params.tableName && databaseName && instanceDatabaseMap[databaseName]) { - newTableName = Object.keys(instanceDatabaseMap[databaseName]).sort()[0]; + // Land on the first database's overview when nothing is selected, and recover from a stale link to + // a database that no longer exists (e.g. just dropped) by falling back to the first one. + const databaseExists = params.databaseName && instanceDatabaseMap[params.databaseName]; + if (!databaseExists) { + const firstDatabaseName = Object.keys(instanceDatabaseMap).sort()[0]; + if (firstDatabaseName && firstDatabaseName !== params.databaseName) { + return ( + + ); + } + } else if (params.tableName && !instanceDatabaseMap[params.databaseName!][params.tableName]) { + // Database exists but the table doesn't (stale link / dropped table): show the DB overview. + return ( + + ); } } - if (newDatabaseName || newTableName) { - return ( - - ); - } return ( -
-
- -
-
- {params.databaseName && params.tableName && ( - - )} -
-
+ <> +
+
+ +
+
+ {params.databaseName && params.tableName + ? ( + + ) + : params.databaseName + ? ( + + ) + : null} +
+
+ {instanceDatabaseMap && } + ); } diff --git a/src/features/instance/databases/modals/CreateNewTableModal.tsx b/src/features/instance/databases/modals/CreateNewTableModal.tsx index ad0de1edc..b9cb5d6f6 100644 --- a/src/features/instance/databases/modals/CreateNewTableModal.tsx +++ b/src/features/instance/databases/modals/CreateNewTableModal.tsx @@ -6,7 +6,6 @@ import { DialogFooter, DialogHeader, DialogTitle, - DialogTrigger, } from '@/components/ui/dialog'; import { Form } from '@/components/ui/form/Form'; import { FormControl } from '@/components/ui/form/FormControl'; @@ -23,8 +22,8 @@ import { tableNameSchema } from '@/integrations/api/instance/database/tableNameS import { zodResolver } from '@hookform/resolvers/zod'; import { useQueryClient } from '@tanstack/react-query'; import { useRouter } from '@tanstack/react-router'; -import { Plus, Table } from 'lucide-react'; -import { useState } from 'react'; +import { Table } from 'lucide-react'; +import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; @@ -42,14 +41,15 @@ const CreateTableSchema = z.object({ }), }); -export function CreateNewTableModal({ databaseName, onSelectTable }: { +export function CreateNewTableModal({ isModalOpen, setIsModalOpen, databaseName, onCreated }: { + readonly isModalOpen: boolean; + readonly setIsModalOpen: (open: boolean) => void; readonly databaseName: string | undefined; - readonly onSelectTable: (databaseName: string | undefined, tableName: string | undefined) => void; + readonly onCreated: (databaseName: string, tableName: string) => void; }) { const queryClient = useQueryClient(); const instanceParams = useInstanceClientIdParams(); const router = useRouter(); - const [isModalOpen, setIsModalOpen] = useState(false); const form = useForm({ resolver: zodResolver(CreateTableSchema), defaultValues: { @@ -59,6 +59,16 @@ export function CreateNewTableModal({ databaseName, onSelectTable }: { }, }); + // This modal is mounted persistently (not remounted per trigger), so re-seed the form with the + // target database each time it opens -- otherwise a right-click on a different database would keep + // the previously-prefilled name. + const { reset } = form; + useEffect(() => { + if (isModalOpen) { + reset({ databaseName: databaseName || '', tableName: '', primaryKey: '' }); + } + }, [isModalOpen, databaseName, reset]); + const { mutate: submitNewTableData } = useCreateTableMutation(); const submitForm = async (formData: z.infer) => { @@ -79,7 +89,7 @@ export function CreateNewTableModal({ databaseName, onSelectTable }: { toast.success(`Table ${tableName} created successfully`); setIsModalOpen(false); form.reset(); - onSelectTable(databaseName, tableName); + onCreated(databaseName, tableName); await router.invalidate(); }, }); @@ -87,16 +97,6 @@ export function CreateNewTableModal({ databaseName, onSelectTable }: { return ( - -
- -
-
Create a New Table diff --git a/src/features/instance/databases/modals/DeleteDatabaseModal.tsx b/src/features/instance/databases/modals/DeleteDatabaseModal.tsx index a73832256..79f6b5f98 100644 --- a/src/features/instance/databases/modals/DeleteDatabaseModal.tsx +++ b/src/features/instance/databases/modals/DeleteDatabaseModal.tsx @@ -9,11 +9,12 @@ import { useRouter } from '@tanstack/react-router'; import { useCallback } from 'react'; import { toast } from 'sonner'; -export function DeleteDatabaseModal({ databaseName, onDeleted }: { - readonly databaseName: string; - readonly onDeleted: (deleted: 'table' | 'database') => void; +export function DeleteDatabaseModal({ onDeleted }: { + readonly onDeleted: (dropped: { databaseName: string }) => void; }) { - const { value: isModalOpen, trigger } = useWatchedValue('ShowDeleteDatabase', false); + const { value: target, trigger } = useWatchedValue('ShowDeleteDatabase', false); + const isModalOpen = !!target; + const databaseName = target ? target.databaseName : ''; const closeModal = useCallback(() => { setWatchedValue('ShowDeleteDatabase', false); attemptToRestoreFocus(trigger); @@ -39,7 +40,7 @@ export function DeleteDatabaseModal({ databaseName, onDeleted }: { }); await router.invalidate(); toast.success(`Database ${databaseName} dropped successfully`); - onDeleted('database'); + onDeleted({ databaseName }); }, }); }, [closeModal, databaseName, deleteDatabase, instanceParams, onDeleted, queryClient, router]); diff --git a/src/features/instance/databases/modals/DeleteTableModal.tsx b/src/features/instance/databases/modals/DeleteTableModal.tsx index adb24c980..70d10bce7 100644 --- a/src/features/instance/databases/modals/DeleteTableModal.tsx +++ b/src/features/instance/databases/modals/DeleteTableModal.tsx @@ -9,12 +9,13 @@ import { useRouter } from '@tanstack/react-router'; import { useCallback } from 'react'; import { toast } from 'sonner'; -export function DeleteTableModal({ databaseName, tableName, onDeleted }: { - readonly databaseName: string; - readonly tableName: string; - readonly onDeleted: (deleted: 'table' | 'database') => void; +export function DeleteTableModal({ onDeleted }: { + readonly onDeleted: (dropped: { databaseName: string; tableName: string }) => void; }) { - const { value: isModalOpen, trigger } = useWatchedValue('ShowDeleteTable', false); + const { value: target, trigger } = useWatchedValue('ShowDeleteTable', false); + const isModalOpen = !!target; + const databaseName = target ? target.databaseName : ''; + const tableName = target ? target.tableName : ''; const closeModal = useCallback(() => { setWatchedValue('ShowDeleteTable', false); attemptToRestoreFocus(trigger); @@ -43,7 +44,7 @@ export function DeleteTableModal({ databaseName, tableName, onDeleted }: { }); await router.invalidate(); toast.success(`Table ${tableName} dropped successfully`); - onDeleted('table'); + onDeleted({ databaseName, tableName }); }, }, ); diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts index 181feeb09..a11ac991b 100644 --- a/src/hooks/usePermissions.ts +++ b/src/hooks/usePermissions.ts @@ -186,7 +186,7 @@ export function useInstanceSchemaTablePermission( return true; } const specificPermission = permission[databaseName]; - return specificPermission?.tables?.[tableName][action] === true; + return specificPermission?.tables?.[tableName]?.[action] === true; } export function useInstanceSchemaTableAttributePermission( @@ -208,7 +208,7 @@ export function useInstanceSchemaTableAttributePermission( return true; } const specificPermission = permission[databaseName]; - if (specificPermission?.tables?.[tableName][action] === true) { + if (specificPermission?.tables?.[tableName]?.[action] === true) { return true; } const table = specificPermission?.tables?.[tableName]; diff --git a/src/lib/storage/sessionStorageKeys.ts b/src/lib/storage/sessionStorageKeys.ts index 406ae30c9..50363f52f 100644 --- a/src/lib/storage/sessionStorageKeys.ts +++ b/src/lib/storage/sessionStorageKeys.ts @@ -8,6 +8,7 @@ export interface SessionStorageKeys { 'EditorFileContent/{entityId}/{path}': true; 'FolderOpened/{entityId}': true; 'FileSelected/{entityId}': true; + 'DatabaseTreeExpanded/{entityId}': true; 'ColumnDisplayed/{database}/{table}': true; 'ShowAllOrganizations': true; } diff --git a/src/lib/storage/watchedValueKeys.ts b/src/lib/storage/watchedValueKeys.ts index 06e57d2b1..8d4c769d6 100644 --- a/src/lib/storage/watchedValueKeys.ts +++ b/src/lib/storage/watchedValueKeys.ts @@ -12,8 +12,13 @@ export interface WatchedValuesTypeMap { ShowDownloadApplicationModal: boolean; ShowRedeployApplicationModal: boolean; ShowRenameFileModal: boolean; - ShowDeleteDatabase: boolean; - ShowDeleteTable: boolean; + // Database/table action triggers carry their target in the payload so the (single, always-mounted) + // modal hub can act on any tree item -- not just the currently-open table. `false` means "closed". + ShowDeleteDatabase: { databaseName: string } | false; + ShowDeleteTable: { databaseName: string; tableName: string } | false; + ShowCreateTable: { databaseName?: string } | false; + ShowAddTableRecords: { databaseName: string; tableName: string } | false; + ShowImportData: { databaseName?: string; tableName?: string } | false; 'Session:{key}': unknown; ReloadApplicationRootEntries: true; FocusEditor: true; From a4222ddd3c53e5cb2b37cfe7776899bd713d6f2f Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 10 Jul 2026 19:50:28 -0400 Subject: [PATCH 2/2] refactor(databases): key action modals per target and defer CSV url revoke Address PR review: - Give the consolidated action modals a per-target `key` so their internal state resets on switch, and drop CreateNewTableModal's manual reset effect (the remount now seeds the prefilled database). - Defer URL.revokeObjectURL in useExportTableCsv so it can't cancel the download before Firefox / iOS Safari start fetching the blob. Co-Authored-By: Claude Opus 4.8 --- .../databases/components/DatabaseActionModals.tsx | 5 +++++ .../instance/databases/hooks/useExportTableCsv.ts | 4 +++- .../databases/modals/CreateNewTableModal.tsx | 13 ++----------- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/features/instance/databases/components/DatabaseActionModals.tsx b/src/features/instance/databases/components/DatabaseActionModals.tsx index 6bf1009ff..7514b7efc 100644 --- a/src/features/instance/databases/components/DatabaseActionModals.tsx +++ b/src/features/instance/databases/components/DatabaseActionModals.tsx @@ -69,6 +69,7 @@ export function DatabaseActionModals({ instanceDatabaseMap }: { instanceDatabase return ( <> setWatchedValue('ShowCreateTable', open ? (createTarget || {}) : false)} databaseName={createTarget ? createTarget.databaseName : undefined} @@ -76,6 +77,7 @@ export function DatabaseActionModals({ instanceDatabaseMap }: { instanceDatabase /> {addTarget && addInstanceTable && ( { @@ -90,6 +92,9 @@ export function DatabaseActionModals({ instanceDatabaseMap }: { instanceDatabase /> )} setWatchedValue('ShowImportData', open ? (importTarget || {}) : false)} instanceDatabaseMap={instanceDatabaseMap} diff --git a/src/features/instance/databases/hooks/useExportTableCsv.ts b/src/features/instance/databases/hooks/useExportTableCsv.ts index 33b051a36..bf93cb069 100644 --- a/src/features/instance/databases/hooks/useExportTableCsv.ts +++ b/src/features/instance/databases/hooks/useExportTableCsv.ts @@ -53,7 +53,9 @@ export function useExportTableCsv() { downloadLink.href = url; downloadLink.setAttribute('download', `${databaseName}.${tableName}.${new Date().toISOString()}.csv`); downloadLink.click(); - URL.revokeObjectURL(url); + // Defer revocation: revoking synchronously can cancel the download before some browsers + // (Firefox, iOS Safari) have started fetching the blob. + setTimeout(() => URL.revokeObjectURL(url), 1000); toast.success('CSV Exported!', { id }); } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to export CSV', { id }); diff --git a/src/features/instance/databases/modals/CreateNewTableModal.tsx b/src/features/instance/databases/modals/CreateNewTableModal.tsx index b9cb5d6f6..d7a5bbcb7 100644 --- a/src/features/instance/databases/modals/CreateNewTableModal.tsx +++ b/src/features/instance/databases/modals/CreateNewTableModal.tsx @@ -23,7 +23,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useQueryClient } from '@tanstack/react-query'; import { useRouter } from '@tanstack/react-router'; import { Table } from 'lucide-react'; -import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; @@ -50,6 +49,8 @@ export function CreateNewTableModal({ isModalOpen, setIsModalOpen, databaseName, const queryClient = useQueryClient(); const instanceParams = useInstanceClientIdParams(); const router = useRouter(); + // The action-modal hub remounts this per target (via a `key`), so the form's defaultValues seed the + // prefilled database correctly on each open -- no manual reset effect needed. const form = useForm({ resolver: zodResolver(CreateTableSchema), defaultValues: { @@ -59,16 +60,6 @@ export function CreateNewTableModal({ isModalOpen, setIsModalOpen, databaseName, }, }); - // This modal is mounted persistently (not remounted per trigger), so re-seed the form with the - // target database each time it opens -- otherwise a right-click on a different database would keep - // the previously-prefilled name. - const { reset } = form; - useEffect(() => { - if (isModalOpen) { - reset({ databaseName: databaseName || '', tableName: '', primaryKey: '' }); - } - }, [isModalOpen, databaseName, reset]); - const { mutate: submitNewTableData } = useCreateTableMutation(); const submitForm = async (formData: z.infer) => {