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..7514b7efc --- /dev/null +++ b/src/features/instance/databases/components/DatabaseActionModals.tsx @@ -0,0 +1,109 @@ +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..bf93cb069 --- /dev/null +++ b/src/features/instance/databases/hooks/useExportTableCsv.ts @@ -0,0 +1,70 @@ +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(); + // 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 }); + } 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..d7a5bbcb7 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,7 @@ 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 { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; @@ -42,14 +40,17 @@ 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); + // 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: { @@ -79,7 +80,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 +88,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;