feat(datatables): move database manager actions into the tree row menus

This commit is contained in:
Diego Imbert
2026-08-25 15:05:43 +02:00
parent b3bee6ed1c
commit 61f5e1b1f3
6 changed files with 164 additions and 37 deletions
+73 -4
View File
@@ -8,6 +8,9 @@
Table2,
Database as DatabaseIcon,
Folder as FolderIcon,
History as HistoryIcon,
KeyRound as KeyRoundIcon,
Download as DownloadIcon,
Trash2Icon,
UploadIcon
} from 'lucide-svelte'
@@ -33,6 +36,7 @@
import type { DbFeatures } from './apps/components/display/dbtable/dbFeatures'
import Star from './Star.svelte'
import type { Asset, DataTableTables } from '$lib/gen'
import type { DatatableRowAction } from './dbTypes'
import TextInput from './text_input/TextInput.svelte'
/** Represents a selected table with its schema */
@@ -63,6 +67,9 @@
datatableTree?: DataTableTables[]
datatableTreeLoading?: boolean
onSelectDatatable?: (datatable: string) => void
/** Row-menu actions on a data table, run against that row's data table. */
onDatatableAction?: (datatable: string, action: DatatableRowAction) => void
canManageDatatable?: boolean
/** Enable multi-select mode with checkboxes in sidebar */
multiSelectMode?: boolean
/** Selected tables in multi-select mode */
@@ -90,6 +97,8 @@
datatableTree,
datatableTreeLoading,
onSelectDatatable,
onDatatableAction,
canManageDatatable = false,
multiSelectMode = false,
selectedTables = $bindable([]),
disabledTables = [],
@@ -217,6 +226,10 @@
// overrides is what lets the current data table and selected schema — which
// default to open — actually be folded; a plain "expanded" set could never
// close them, since the default would keep winning.
// Schema-level permissions are not built yet; the drawer is the shell the row
// menu already opens onto.
let schemaPermissionsOpen = $state(false)
let expandOverrides = $state<Map<string, boolean>>(new Map())
const nodeKey = (dt: string | undefined, schemaKey?: string) =>
`${dt ?? ''}${schemaKey === undefined ? '' : `/${schemaKey}`}`
@@ -240,6 +253,12 @@
expandOverrides = next
}
// Row actions stay out of the way until you are on the row — or it is the one
// you are looking at, where the menu is part of the current context.
const rowActionsClass = (current: boolean) =>
'w-fit -mr-1 transition-opacity focus-within:opacity-100 group-hover:opacity-100 ' +
(current ? 'opacity-100' : 'opacity-0')
/** Every row in the tree reads the same way: what you are looking at now is
* emphasized, everything else recedes. */
const rowText = (current: boolean) =>
@@ -557,12 +576,45 @@
{@const dtOpen = isExpanded(root.datatable)}
{#if root.datatable !== undefined}
<button
class={'w-full text-sm flex gap-2 items-center h-8 cursor-pointer pl-2 pr-1 hover:bg-gray-500/10 ' +
class={'group w-full text-sm flex gap-2 items-center h-8 cursor-pointer pl-2 pr-1 hover:bg-gray-500/10 ' +
rowText(root.datatable === currentDatatable)}
onclick={() => toggle(root.datatable)}
>
<DatabaseIcon class="shrink-0" size={14} />
<span class="truncate text-ellipsis grow text-left text-xs">{root.datatable}</span>
{#if onDatatableAction}
{@const dt = root.datatable}
<DropdownV2
items={() => [
{
displayName: 'Migrations',
icon: HistoryIcon,
action: () => onDatatableAction?.(dt, 'migrations')
},
...(canManageDatatable
? [
{
displayName: 'Roles',
icon: KeyRoundIcon,
action: () => onDatatableAction?.(dt, 'roles')
}
]
: []),
{
displayName: 'Export',
icon: DownloadIcon,
action: () => onDatatableAction?.(dt, 'export')
},
{
displayName: 'Import',
icon: UploadIcon,
action: () => onDatatableAction?.(dt, 'import')
}
]}
class={rowActionsClass(root.datatable === currentDatatable)}
btnId={'db-manager-datatable-actions-' + onlyAlphaNumAndUnderscore(dt)}
/>
{/if}
<ChevronDownIcon
class={'shrink-0 mr-1 text-secondary transition-transform ' +
(dtOpen ? '' : '-rotate-90')}
@@ -579,7 +631,7 @@
{@const indent = root.datatable !== undefined ? 'pl-6' : 'pl-2'}
{#if dbSupportsSchemas}
<button
class={'w-full text-sm flex gap-2 items-center h-8 cursor-pointer pr-1 hover:bg-gray-500/10 ' +
class={'group w-full text-sm flex gap-2 items-center h-8 cursor-pointer pr-1 hover:bg-gray-500/10 ' +
indent +
' ' +
rowText(
@@ -589,6 +641,19 @@
>
<FolderIcon class="shrink-0" size={14} />
<span class="truncate text-ellipsis grow text-left text-xs">{sc.schemaKey}</span>
<DropdownV2
items={() => [
{
displayName: 'Permissions',
icon: KeyRoundIcon,
action: () => (schemaPermissionsOpen = true)
}
]}
class={rowActionsClass(
root.datatable === currentDatatable && sc.schemaKey === selected.schemaKey
)}
btnId={'db-manager-schema-actions-' + onlyAlphaNumAndUnderscore(sc.schemaKey)}
/>
<ChevronDownIcon
class={'shrink-0 mr-1 text-secondary transition-transform ' +
(schemaOpen ? '' : '-rotate-90')}
@@ -670,7 +735,7 @@
}
}
]}
class="w-fit -mr-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
class={rowActionsClass(isSelected)}
btnId={'db-manager-table-actions-' + onlyAlphaNumAndUnderscore(tableKey)}
/>
{/if}
@@ -743,7 +808,11 @@
</Splitpanes>
<Portal>
<ConfirmationModal
<Drawer bind:open={schemaPermissionsOpen} size="900px">
<DrawerContent title="Schema permissions" on:close={() => (schemaPermissionsOpen = false)} />
</Drawer>
<ConfirmationModal
{...askingForConfirmation ?? { confirmationText: '', title: '' }}
on:canceled={() => (askingForConfirmation = undefined)}
on:confirmed={askingForConfirmation?.onConfirm ?? (() => {})}
@@ -2,6 +2,7 @@
import { dbSchemas, workspaceStore, type DBSchema } from '$lib/stores'
import type { Snippet } from 'svelte'
import type { DataTableTables } from '$lib/gen'
import type { DatatableRowAction } from './dbTypes'
import { sortArray } from '$lib/utils'
import { Loader2, RefreshCcw } from 'lucide-svelte'
import Alert from './common/alert/Alert.svelte'
@@ -44,6 +45,8 @@
datatableTree?: DataTableTables[]
datatableTreeLoading?: boolean
onSelectDatatable?: (datatable: string) => void
onDatatableAction?: (datatable: string, action: DatatableRowAction) => void
canManageDatatable?: boolean
/** Enable multi-select mode with checkboxes in sidebar */
multiSelectMode?: boolean
/** Selected tables in multi-select mode */
@@ -70,6 +73,8 @@
datatableTree,
datatableTreeLoading,
onSelectDatatable,
onDatatableAction,
canManageDatatable,
multiSelectMode = false,
selectedTables = $bindable([]),
disabledTables = [],
@@ -322,6 +327,8 @@
{datatableTree}
{datatableTreeLoading}
{onSelectDatatable}
{onDatatableAction}
{canManageDatatable}
{onImport}
bind:selectedSchemaKey
bind:selectedTableKey
@@ -8,18 +8,17 @@
import {
ArrowLeft,
Copy,
Download,
Expand,
Minimize,
RefreshCcw,
Upload
} from 'lucide-svelte'
import DBManagerContent from './DBManagerContent.svelte'
import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte'
import DataTablePermissionsButton from './workspaceSettings/DataTablePermissionsButton.svelte'
import { resource } from 'runed'
import { untrack } from 'svelte'
import { tick, untrack } from 'svelte'
import type { DbManagerUriState } from './dbManagerDrawerModel.svelte'
import type { DatatableRowAction } from './dbTypes'
import ResourcePicker from './ResourcePicker.svelte'
import Alert from './common/alert/Alert.svelte'
import { sendUserToast } from '$lib/toast'
@@ -135,6 +134,8 @@
let importDrawerOpen = $state(false)
let importLoading = $state(false)
let importSource = $state<string | undefined>(undefined)
/** Which database an import writes into; set when driven from a tree row. */
let importTarget = $state<string | undefined>(undefined)
let importBehavior = $state<'schema_only' | 'schema_and_data'>('schema_only')
let isPostgresqlInput = $derived(
@@ -154,13 +155,41 @@
return toSourceIdentifier(input.resourcePath)
}
// The tree's row menus act on the data table of the row that was clicked, which
// is not necessarily the one currently open — so the target is set first and the
// headless modals are keyed on it.
let actionDatatable = $state<string | undefined>(undefined)
let migrationsModal = $state<DataTableMigrationsButton | undefined>()
let permissionsDrawer = $state<DataTablePermissionsButton | undefined>()
async function runDatatableAction(datatable: string, action: DatatableRowAction) {
actionDatatable = datatable
// Let the keyed block above mount against the new target before driving it.
await tick()
switch (action) {
case 'migrations':
migrationsModal?.open()
break
case 'roles':
permissionsDrawer?.openPermissions()
break
case 'export':
await handleExportSchema(`datatable://${datatable}`)
break
case 'import':
importTarget = `datatable://${datatable}`
importDrawerOpen = true
break
}
}
function refreshManager() {
dbManagerContent?.refresh()
dbManagerContent?.dbManager()?.dbTable()?.refresh()
}
async function handleExportSchema() {
const source = currentSourceIdentifier()
async function handleExportSchema(explicitSource?: string) {
const source = explicitSource ?? currentSourceIdentifier()
if (!source || !ws) return
try {
exportResult = await WorkspaceService.exportPgSchema({
@@ -175,7 +204,7 @@
async function handleImportDatabase() {
if (!importSource || !ws) return
const target = currentSourceIdentifier()
const target = importTarget ?? currentSourceIdentifier()
if (!target) return
importLoading = true
try {
@@ -230,6 +259,8 @@
datatableTree={uriState.isDatatableInput ? datatables.current : undefined}
datatableTreeLoading={datatables.loading}
onSelectDatatable={(dt) => (uriState.selectedDatatable = dt)}
canManageDatatable={!!($superadmin || $userStore?.is_admin)}
onDatatableAction={runDatatableAction}
bind:workerTag={() => workerTag.tag, (v) => (workerTag.tag = v)}
bind:hasReplResult
bind:selectedSchemaKey={uriState.selectedSchema}
@@ -242,22 +273,6 @@
{/key}
{/if}
{#snippet actions()}
{#if uriState.isDatatableInput && uriState.selectedDatatable && ws}
<DataTableMigrationsButton
workspace={ws}
datatable={uriState.selectedDatatable}
onSchemaChanged={refreshManager}
/>
{#if $superadmin || $userStore?.is_admin}
<DataTablePermissionsButton workspace={ws} datatable={uriState.selectedDatatable} />
{/if}
{/if}
{#if enableImportExport}
<Button startIcon={{ icon: Download }} onClick={handleExportSchema}>Export</Button>
<Button startIcon={{ icon: Upload }} onClick={() => (importDrawerOpen = true)}>
Import
</Button>
{/if}
{#if uriState.effectiveInput && ws}
<DbWorkerTagButton
bind:tag={() => workerTag.tag, (v) => (workerTag.tag = v)}
@@ -286,6 +301,22 @@
</DrawerContent>
</Drawer>
{#if actionDatatable && ws}
<DataTableMigrationsButton
bind:this={migrationsModal}
hideTrigger
workspace={ws}
datatable={actionDatatable}
onSchemaChanged={refreshManager}
/>
<DataTablePermissionsButton
bind:this={permissionsDrawer}
hideTrigger
workspace={ws}
datatable={actionDatatable}
/>
{/if}
<Drawer bind:open={exportDrawerOpen} size="800px" offset={offset + 1}>
<DrawerContent title="Export Schemas" on:close={() => (exportDrawerOpen = false)}>
{#if exportResult}
+3
View File
@@ -26,3 +26,6 @@ export const dbTypes = [
'duckdb'
] as const
export const isDbType = (str?: string): str is DbType => !!str && dbTypes.includes(str as DbType)
/** A row-menu action on a data table in the database manager's tree. */
export type DatatableRowAction = 'migrations' | 'roles' | 'export' | 'import'
@@ -106,6 +106,11 @@
// Open the list modal and the detail view for a specific migration. Used to
// jump to a just-created migration from the "See migration" toast action.
/** Open the migrations list without the trigger button (tree menus drive it). */
export function open() {
openList()
}
export async function openMigration(timestamp: number) {
listOpen = true
await loadMigrations()
@@ -31,11 +31,15 @@
let {
workspace,
datatable,
disabled = false
disabled = false,
hideTrigger = false
}: {
workspace: string
datatable: string
disabled?: boolean
/** Mount the drawer without its button, so a caller can drive it via `open()`
* (the database manager opens it from the tree's row menu). */
hideTrigger?: boolean
} = $props()
const ADMIN_ROLE = 'admin'
@@ -124,6 +128,12 @@
load()
}
/** Open the drawer without the trigger button (the database manager's tree
* row menu drives it). Not named `open`: that is the drawer's own state. */
export function openPermissions() {
openDrawer()
}
function addRole() {
roles.push({ id: randomUUID(), name: '', tenants: [] })
}
@@ -208,16 +218,18 @@
}
</script>
<Button
size="xs"
color="light"
variant="border"
startIcon={{ icon: KeyRound }}
iconOnly
{disabled}
title="Permissions: restrict who can use this data table, and as which database role"
on:click={openDrawer}
/>
{#if !hideTrigger}
<Button
size="xs"
color="light"
variant="border"
startIcon={{ icon: KeyRound }}
iconOnly
{disabled}
title="Permissions: restrict who can use this data table, and as which database role"
on:click={openDrawer}
/>
{/if}
<Drawer bind:open size="900px">
<DrawerContent