mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 00:02:13 +00:00
feat(datatables): move database manager actions into the tree row menus
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user