mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 08:01:35 +00:00
The menu now replaces a row's own icon on hover rather than its chevron, and a table row keeps its icon with the favourite star moved to the far right. Table menus are no longer limited to the data table the manager is connected to: alter and delete route through the same switch the create rows use, so they run against the connection of the data table the row belongs to.
929 lines
33 KiB
Svelte
929 lines
33 KiB
Svelte
<script lang="ts">
|
|
import { superadmin, userStore, type DBSchema } from '$lib/stores'
|
|
import {
|
|
ChevronDownIcon,
|
|
EditIcon,
|
|
Loader2,
|
|
Plus,
|
|
Table2,
|
|
Database as DatabaseIcon,
|
|
Folder as FolderIcon,
|
|
History as HistoryIcon,
|
|
KeyRound as KeyRoundIcon,
|
|
Download as DownloadIcon,
|
|
Trash2Icon,
|
|
UploadIcon
|
|
} from 'lucide-svelte'
|
|
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
|
import { ClearableInput, Drawer, DrawerContent } from './common'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { type ColumnDef } from './apps/components/display/dbtable/utils'
|
|
import DBTable from './DBTable.svelte'
|
|
import type { IDbSchemaOps, IDbTableOps } from './dbOps'
|
|
import DropdownV2 from './DropdownV2.svelte'
|
|
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
|
import Button from './common/button/Button.svelte'
|
|
import DbTableEditor from './DBTableEditor.svelte'
|
|
import type { DbType } from './dbTypes'
|
|
import Portal from './Portal.svelte'
|
|
import {
|
|
dbSupportsTransactionalDdl,
|
|
diffTableEditorValues
|
|
} from './apps/components/display/dbtable/queries/alterTable'
|
|
import { resource } from 'runed'
|
|
import { capitalize, onlyAlphaNumAndUnderscore, pluralize } from '$lib/utils'
|
|
import type { DbFeatures } from './apps/components/display/dbtable/dbFeatures'
|
|
import Star from './Star.svelte'
|
|
import Badge from './common/badge/Badge.svelte'
|
|
import type { Asset, DataTableTables } from '$lib/gen'
|
|
import { ADMIN_DATATABLE_ROLE, type DatatableRowAction } from './dbTypes'
|
|
import TextInput from './text_input/TextInput.svelte'
|
|
import Checkbox from './common/checkbox/Checkbox.svelte'
|
|
|
|
/** Represents a selected table with its schema */
|
|
export interface SelectedTable {
|
|
/** Absent when the tree has no data table level (a plain database). */
|
|
datatable?: string
|
|
schema: string
|
|
table: string
|
|
}
|
|
|
|
/** A row action started on a data table other than the current one. Switching
|
|
* data table re-mounts this component, so the request — which has to run
|
|
* against the connection of the data table it names — travels through the
|
|
* parent. */
|
|
export type PendingRowAction =
|
|
| { kind: 'create-table'; schema: string }
|
|
| { kind: 'create-schema' }
|
|
| { kind: 'alter-table'; schema: string; table: string }
|
|
| { kind: 'delete-table'; schema: string; table: string }
|
|
|
|
type Props = {
|
|
dbType: DbType
|
|
dbSchema: DBSchema
|
|
dbSupportsSchemas: boolean
|
|
databaseIsEmpty?: boolean
|
|
colDefs: Record<string, ColumnDef[]> | undefined
|
|
dbTableOpsFactory: (params: { colDefs: ColumnDef[]; tableKey: string }) => IDbTableOps
|
|
dbSchemaOps: IDbSchemaOps
|
|
refresh?: () => void
|
|
initialSchemaKey?: string
|
|
initialTableKey?: string
|
|
selectedSchemaKey?: string | undefined
|
|
selectedTableKey?: string | undefined
|
|
/** Every data table with its schemas and tables. Present only when the manager
|
|
* is on a data table — that is what puts a data-table level at the top of the
|
|
* tree; otherwise the tree starts at schemas. */
|
|
datatableTree?: DataTableTables[]
|
|
datatableTreeLoading?: boolean
|
|
onSelectDatatable?: (datatable: string) => void
|
|
/** Role the manager is connected as, when it is not the default one. */
|
|
currentRole?: string
|
|
pendingAction?: PendingRowAction | undefined
|
|
/** 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 */
|
|
selectedTables?: SelectedTable[]
|
|
/** Tables that are already added and should show as disabled */
|
|
disabledTables?: SelectedTable[]
|
|
features?: DbFeatures
|
|
asset?: Asset
|
|
onImport?: (mode: 'schema_and_data' | 'schema_only') => void
|
|
}
|
|
let {
|
|
dbType,
|
|
dbSchema,
|
|
dbTableOpsFactory,
|
|
dbSchemaOps,
|
|
dbSupportsSchemas,
|
|
databaseIsEmpty,
|
|
colDefs,
|
|
refresh,
|
|
initialSchemaKey,
|
|
initialTableKey,
|
|
selectedSchemaKey = $bindable(undefined),
|
|
selectedTableKey = $bindable(undefined),
|
|
datatableTree,
|
|
datatableTreeLoading,
|
|
onSelectDatatable,
|
|
currentRole,
|
|
pendingAction = $bindable(undefined),
|
|
onDatatableAction,
|
|
canManageDatatable = false,
|
|
multiSelectMode = false,
|
|
selectedTables = $bindable([]),
|
|
disabledTables = [],
|
|
features,
|
|
asset,
|
|
onImport
|
|
}: Props = $props()
|
|
|
|
const sameTable = (a: SelectedTable, b: SelectedTable) =>
|
|
a.datatable === b.datatable && a.schema === b.schema && a.table === b.table
|
|
|
|
function isTableSelected(t: SelectedTable): boolean {
|
|
return selectedTables.some((s) => sameTable(s, t))
|
|
}
|
|
|
|
/** Already added by the caller: shown ticked and locked. */
|
|
function isTableDisabled(t: SelectedTable): boolean {
|
|
return disabledTables.some((s) => sameTable(s, t))
|
|
}
|
|
|
|
function toggleTableSelection(t: SelectedTable) {
|
|
if (isTableDisabled(t)) return
|
|
selectedTables = isTableSelected(t)
|
|
? selectedTables.filter((s) => !sameTable(s, t))
|
|
: [...selectedTables, t]
|
|
}
|
|
|
|
/** Every table under a node, as it is currently rendered — so a batch toggle
|
|
* acts on what the user can see, search filter included. */
|
|
function tablesUnder(datatable: string | undefined, schemaKey?: string): SelectedTable[] {
|
|
return treeRoots
|
|
.filter((r) => r.datatable === datatable)
|
|
.flatMap((r) =>
|
|
r.schemas
|
|
.filter((sc) => schemaKey === undefined || sc.schemaKey === schemaKey)
|
|
.flatMap((sc) => sc.tables.map((table) => ({ datatable, schema: sc.schemaKey, table })))
|
|
)
|
|
}
|
|
|
|
/** Tri-state of a node's batch checkbox. Locked tables count as ticked, so a
|
|
* node whose tables were all already added reads as full rather than empty. */
|
|
function batchState(
|
|
datatable: string | undefined,
|
|
schemaKey?: string
|
|
): { checked: boolean; indeterminate: boolean; disabled: boolean } {
|
|
const tables = tablesUnder(datatable, schemaKey)
|
|
const selectable = tables.filter((t) => !isTableDisabled(t))
|
|
const n = tables.filter((t) => isTableSelected(t) || isTableDisabled(t)).length
|
|
return {
|
|
checked: tables.length > 0 && n === tables.length,
|
|
indeterminate: n > 0 && n < tables.length,
|
|
disabled: selectable.length === 0
|
|
}
|
|
}
|
|
|
|
function toggleBatch(datatable: string | undefined, schemaKey?: string) {
|
|
const selectable = tablesUnder(datatable, schemaKey).filter((t) => !isTableDisabled(t))
|
|
if (selectable.every((t) => isTableSelected(t))) {
|
|
selectedTables = selectedTables.filter((s) => !selectable.some((t) => sameTable(s, t)))
|
|
} else {
|
|
const missing = selectable.filter((t) => !isTableSelected(t))
|
|
selectedTables = [...selectedTables, ...missing]
|
|
}
|
|
}
|
|
|
|
let schemaKeys = $derived(Object.keys(dbSchema.schema ?? {}))
|
|
|
|
// --- Left-pane tree ---------------------------------------------------------
|
|
// Levels: data table -> schema -> table. The top two collapse away on their own
|
|
// terms: no `datatableTree` means this is not a data table, and a database
|
|
// without schemas has nothing to put between a data table and its tables.
|
|
const currentDatatable = $derived(asset?.kind === 'datatable' ? asset.path : undefined)
|
|
|
|
/** Favourites are keyed by the table's own asset URI, so a row under another
|
|
* data table must not borrow the one the manager is currently pointed at. */
|
|
function tableAssetPath(datatable: string | undefined, schemaKey: string, tableKey: string) {
|
|
const kind = datatable !== undefined ? 'datatable' : asset!.kind
|
|
const path = datatable ?? asset!.path
|
|
return `${kind}://${path === 'main' ? '' : path}/${schemaKey}.${tableKey}`
|
|
}
|
|
|
|
/** Tables per schema for a data table, as `schema -> table[]`. */
|
|
function schemasOf(datatable: string | undefined): Record<string, string[]> {
|
|
// The open data table reads from `dbSchema`, which is refetched after a DDL;
|
|
// the tree snapshot is not, so using it here would hide a table until the
|
|
// next full reload.
|
|
if (datatable === undefined || datatable === currentDatatable) {
|
|
return Object.fromEntries(
|
|
Object.entries(dbSchema.schema ?? {}).map(([sk, tables]) => [sk, Object.keys(tables ?? {})])
|
|
)
|
|
}
|
|
return datatableTree?.find((d) => d.datatable_name === datatable)?.schemas ?? {}
|
|
}
|
|
|
|
function errorOf(datatable: string): string | undefined {
|
|
return datatableTree?.find((d) => d.datatable_name === datatable)?.error
|
|
}
|
|
|
|
/** The role a data table row is reached through, shown as a badge. Left out
|
|
* where naming it says nothing: a data table without permissions, or one whose
|
|
* single usable role is already the implicit `admin`. */
|
|
function roleOf(datatable: string): string | undefined {
|
|
const entry = datatableTree?.find((d) => d.datatable_name === datatable)
|
|
const usable = entry?.usable_roles ?? []
|
|
if (usable.length === 0 || (usable.length === 1 && usable[0] === ADMIN_DATATABLE_ROLE)) {
|
|
return undefined
|
|
}
|
|
return (datatable === currentDatatable ? currentRole : undefined) ?? entry?.default_role
|
|
}
|
|
|
|
const matchesSearch = (t: string) => t.toLowerCase().includes(search.trim().toLowerCase())
|
|
|
|
/** The tree as rendered: only nodes with a matching descendant survive a search. */
|
|
let treeRoots = $derived.by(() => {
|
|
const datatables = datatableTree
|
|
? datatableTree.map((d) => d.datatable_name)
|
|
: [undefined as string | undefined]
|
|
return datatables
|
|
.map((dt) => {
|
|
const schemas = Object.entries(schemasOf(dt))
|
|
.map(([schemaKey, tables]) => ({
|
|
schemaKey,
|
|
// A schema that matches keeps all of its tables — the search named
|
|
// the schema, so what is in it is the answer. Copy before sorting:
|
|
// `tables` belongs to the tree snapshot, which is reactive state.
|
|
tables: (matchesSearch(schemaKey) ? [...tables] : tables.filter(matchesSearch)).sort()
|
|
}))
|
|
.filter(
|
|
(sc) => search.trim() === '' || matchesSearch(sc.schemaKey) || sc.tables.length > 0
|
|
)
|
|
schemas.sort((a, b) => a.schemaKey.localeCompare(b.schemaKey))
|
|
return { datatable: dt, schemas, error: dt ? errorOf(dt) : undefined }
|
|
})
|
|
.filter(
|
|
// A search narrows the tree to what matched; a data table with no match
|
|
// left in it would otherwise sit there as an empty row.
|
|
(root) => search.trim() === '' || root.schemas.length > 0
|
|
)
|
|
})
|
|
|
|
// Explicit open/closed choices, over a default rule. Storing only the
|
|
// 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}`}`
|
|
|
|
function defaultExpanded(dt: string | undefined, schemaKey?: string): boolean {
|
|
if (dt !== undefined && dt !== currentDatatable) return false
|
|
return schemaKey === undefined || schemaKey === selected.schemaKey
|
|
}
|
|
|
|
function isExpanded(dt: string | undefined, schemaKey?: string): boolean {
|
|
// A search narrows the tree to what matched, so everything left is shown.
|
|
if (search.trim() !== '') return true
|
|
return expandOverrides.get(nodeKey(dt, schemaKey)) ?? defaultExpanded(dt, schemaKey)
|
|
}
|
|
|
|
function toggle(dt: string | undefined, schemaKey?: string) {
|
|
const key = nodeKey(dt, schemaKey)
|
|
const open = expandOverrides.get(key) ?? defaultExpanded(dt, schemaKey)
|
|
const next = new Map(expandOverrides)
|
|
next.set(key, !open)
|
|
expandOverrides = next
|
|
}
|
|
|
|
// The row menu takes the place of the row's own icon while you are on the row.
|
|
const rowActionsClass =
|
|
'absolute left-1/2 -translate-x-1/2 opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100'
|
|
const rowIconClass = (hasMenu: boolean) =>
|
|
'shrink-0 transition-opacity ' + (hasMenu ? 'group-hover:opacity-0' : '')
|
|
|
|
const rowChevronClass = (open: boolean) =>
|
|
'shrink-0 text-secondary transition-transform ' + (open ? '' : '-rotate-90')
|
|
|
|
/** Reveal a node, dropping a stale "closed" that would hide a new selection. */
|
|
function reveal(dt: string | undefined, schemaKey?: string) {
|
|
const next = new Map(expandOverrides)
|
|
next.delete(nodeKey(dt))
|
|
next.delete(nodeKey(dt, schemaKey))
|
|
expandOverrides = next
|
|
}
|
|
|
|
function selectTable(dt: string | undefined, schemaKey: string, tableKey: string) {
|
|
if (dt !== undefined && dt !== currentDatatable) {
|
|
// Switching data table re-mounts this component against the new one, so
|
|
// the target has to travel through the bound keys the parent keeps —
|
|
// local state here is about to be thrown away.
|
|
selectedSchemaKey = schemaKey
|
|
selectedTableKey = tableKey
|
|
onSelectDatatable?.(dt)
|
|
return
|
|
}
|
|
reveal(dt, schemaKey)
|
|
selected = { schemaKey, tableKey }
|
|
}
|
|
|
|
/** Run a row action on the data table it belongs to, switching to it first
|
|
* when it is not the one the manager is connected to. */
|
|
function onDatatable(dt: string | undefined, action: PendingRowAction): boolean {
|
|
if (dt === undefined || dt === currentDatatable) return true
|
|
if ('schema' in action) selectedSchemaKey = action.schema
|
|
if ('table' in action) selectedTableKey = action.table
|
|
pendingAction = action
|
|
onSelectDatatable?.(dt)
|
|
return false
|
|
}
|
|
|
|
function startCreateTable(dt: string | undefined, schema: string) {
|
|
if (!onDatatable(dt, { kind: 'create-table', schema })) return
|
|
selected = { schemaKey: schema, tableKey: undefined }
|
|
dbTableEditorState = { open: true }
|
|
}
|
|
|
|
function startCreateSchema(dt: string | undefined) {
|
|
if (!onDatatable(dt, { kind: 'create-schema' })) return
|
|
newSchemaDialogOpen = true
|
|
}
|
|
|
|
function startAlterTable(dt: string | undefined, schema: string, table: string) {
|
|
if (!onDatatable(dt, { kind: 'alter-table', schema, table })) return
|
|
selected = { schemaKey: schema, tableKey: table }
|
|
dbTableEditorState = { open: true, alterTableKey: table }
|
|
}
|
|
|
|
function startDeleteTable(dt: string | undefined, schema: string, table: string) {
|
|
if (!onDatatable(dt, { kind: 'delete-table', schema, table })) return
|
|
askingForConfirmation = {
|
|
title: `Are you sure you want to delete ${table} ? This action is irreversible`,
|
|
confirmationText: 'Delete permanently',
|
|
open: true,
|
|
id: 'db-manager-delete-table-confirmation-modal',
|
|
onConfirm: async () => {
|
|
askingForConfirmation && (askingForConfirmation.loading = true)
|
|
try {
|
|
await dbSchemaOps.onDelete({ tableKey: table, schema })
|
|
refresh?.()
|
|
sendUserToast(`Table '${table}' deleted successfully`)
|
|
} catch (e) {
|
|
let msg: string | undefined = (e as any).body ?? (e as Error).message
|
|
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : undefined
|
|
sendUserToast(msg ?? 'Action failed!', true)
|
|
}
|
|
askingForConfirmation = undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
// Finishes an action requested before the switch, now that this component is
|
|
// mounted against the data table it targeted.
|
|
$effect(() => {
|
|
const req = pendingAction
|
|
if (!req || !schemaKeys.length) return
|
|
pendingAction = undefined
|
|
if (req.kind === 'create-schema') {
|
|
newSchemaDialogOpen = true
|
|
return
|
|
}
|
|
if (!schemaKeys.includes(req.schema)) return
|
|
if (req.kind === 'create-table') startCreateTable(undefined, req.schema)
|
|
else if (req.kind === 'alter-table') startAlterTable(undefined, req.schema, req.table)
|
|
else startDeleteTable(undefined, req.schema, req.table)
|
|
})
|
|
|
|
let search = $state('')
|
|
let selected: {
|
|
schemaKey?: undefined | string
|
|
tableKey?: undefined | string
|
|
} = $state({})
|
|
|
|
$effect(() => {
|
|
if (!selected.schemaKey && schemaKeys.length) {
|
|
let schemaKey =
|
|
initialSchemaKey ??
|
|
('public' in dbSchema.schema
|
|
? 'public'
|
|
: 'dbo' in dbSchema.schema
|
|
? 'dbo'
|
|
: 'main' in dbSchema.schema
|
|
? 'main'
|
|
: schemaKeys[0])
|
|
let tableKey =
|
|
initialTableKey && dbSchema.schema?.[schemaKey]?.[initialTableKey]
|
|
? initialTableKey
|
|
: undefined
|
|
selected = { schemaKey, tableKey }
|
|
}
|
|
})
|
|
|
|
// Sync selected state with bindable props
|
|
$effect(() => {
|
|
if (selected.schemaKey) {
|
|
selectedSchemaKey = selected.schemaKey
|
|
}
|
|
if (selected.tableKey) {
|
|
selectedTableKey = selected.tableKey
|
|
}
|
|
})
|
|
|
|
let tableKeys = $derived.by(() => {
|
|
if (dbSchema.lang === 'graphql') {
|
|
sendUserToast('graphql not supported by DBExplorerTable', true)
|
|
return []
|
|
}
|
|
if (!selected.schemaKey) return []
|
|
return Object.keys(dbSchema.schema[selected.schemaKey] ?? {})
|
|
})
|
|
|
|
$effect(() => {
|
|
if (tableKeys.length && !selected.tableKey) {
|
|
selected.tableKey = filteredTableKeys[0]
|
|
}
|
|
})
|
|
|
|
let filteredTableKeys = $derived.by(() => {
|
|
const l = tableKeys.filter((tk) => tk.includes(search))
|
|
l.sort()
|
|
return l
|
|
})
|
|
|
|
let tableKey = $derived(
|
|
dbSupportsSchemas && selected.schemaKey
|
|
? `${selected.schemaKey}.${selected.tableKey}`
|
|
: selected.tableKey
|
|
)
|
|
|
|
let askingForConfirmation:
|
|
| (ConfirmationModal['$$prop_def'] & { onConfirm: () => void })
|
|
| undefined = $state()
|
|
|
|
let dbTableEditorState:
|
|
| { open: boolean; alterTableKey?: undefined }
|
|
| { open: true; alterTableKey: string } = $state({
|
|
open: false
|
|
})
|
|
let dbTableEditorAlterTableData = resource(
|
|
[() => dbTableEditorState.alterTableKey, () => colDefs],
|
|
async ([table]) => {
|
|
if (!table) return
|
|
let tableKey2 =
|
|
dbSupportsSchemas && selected.schemaKey ? `${selected.schemaKey}.${table}` : table
|
|
if (!colDefs?.[tableKey2]) return
|
|
return await dbSchemaOps.onFetchTableEditorDefinition({
|
|
table: table,
|
|
schema: selected.schemaKey,
|
|
colDefs: colDefs[tableKey2]
|
|
})
|
|
}
|
|
)
|
|
|
|
let newSchemaDialogOpen = $state(false)
|
|
let newSchemaName = $state('')
|
|
|
|
// Check if the sanitized schema name already exists
|
|
const sanitizedNewSchemaName = $derived.by(() => {
|
|
let s = newSchemaName.trim().replace(/[^a-zA-Z0-9_]/g, '')
|
|
if (dbType === 'snowflake') s = s.toUpperCase()
|
|
return s
|
|
})
|
|
const schemaAlreadyExists = $derived(
|
|
sanitizedNewSchemaName !== '' &&
|
|
schemaKeys.map((s) => s.toLowerCase()).includes(sanitizedNewSchemaName.toLowerCase())
|
|
)
|
|
|
|
let _dbTable: DBTable | undefined = $state()
|
|
export const dbTable = () => _dbTable
|
|
</script>
|
|
|
|
<Splitpanes>
|
|
<Pane size={24} class="relative flex flex-col">
|
|
<div class="mx-3 mt-3 flex flex-col gap-2">
|
|
<TextInput bind:value={search} inputProps={{ placeholder: 'Search table or schema...' }} />
|
|
</div>
|
|
<div class="overflow-x-clip overflow-y-auto relative mt-1.5 flex-1">
|
|
<!-- Normal mode: data table -> schema -> table, each level dropping out
|
|
when it has nothing to say (no data table / no schemas). -->
|
|
{#if datatableTreeLoading && (datatableTree?.length ?? 0) === 0}
|
|
<div class="flex items-center gap-2 text-tertiary p-3">
|
|
<Loader2 class="animate-spin" size={14} />
|
|
<span class="text-xs">Loading...</span>
|
|
</div>
|
|
{/if}
|
|
{#each treeRoots as root (root.datatable ?? '')}
|
|
{@const dtOpen = isExpanded(root.datatable)}
|
|
{#if root.datatable !== undefined}
|
|
{@const hasMenu = !multiSelectMode && onDatatableAction !== undefined}
|
|
<button
|
|
class="group w-full text-xs font-normal text-primary flex gap-2 items-center h-8 cursor-pointer pl-3 pr-1 hover:bg-gray-500/10"
|
|
onclick={() => toggle(root.datatable)}
|
|
>
|
|
{#if multiSelectMode}
|
|
{@const state = batchState(root.datatable)}
|
|
<Checkbox
|
|
checked={state.checked}
|
|
indeterminate={state.indeterminate}
|
|
disabled={state.disabled}
|
|
onChange={() => toggleBatch(root.datatable)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
class="shrink-0"
|
|
/>
|
|
{/if}
|
|
<div class="relative shrink-0 h-8 w-3.5 flex items-center justify-center">
|
|
<DatabaseIcon class={rowIconClass(hasMenu)} size={14} />
|
|
{#if hasMenu}
|
|
{@const dt = root.datatable}
|
|
<DropdownV2
|
|
enableFlyTransition
|
|
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}
|
|
btnId={'db-manager-datatable-actions-' + onlyAlphaNumAndUnderscore(dt)}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
<span class="truncate text-ellipsis text-left text-xs">{root.datatable}</span>
|
|
{#if roleOf(root.datatable) !== undefined}
|
|
<Badge small color="gray">{roleOf(root.datatable)}</Badge>
|
|
{/if}
|
|
<div class="grow"></div>
|
|
<div class="shrink-0 w-6 h-8 flex items-center justify-end mr-2">
|
|
<ChevronDownIcon class={rowChevronClass(dtOpen)} size={14} />
|
|
</div>
|
|
</button>
|
|
{/if}
|
|
{#if dtOpen}
|
|
{#if root.error}
|
|
<p class="text-xs text-red-400 px-3 py-2">{root.error}</p>
|
|
{/if}
|
|
{#each root.schemas as sc (sc.schemaKey)}
|
|
{@const schemaOpen = isExpanded(root.datatable, sc.schemaKey)}
|
|
{@const indent = root.datatable !== undefined ? 'pl-7' : 'pl-3'}
|
|
{#if dbSupportsSchemas}
|
|
<button
|
|
class={'group w-full text-xs font-normal text-primary flex gap-2 items-center h-8 cursor-pointer pr-1 hover:bg-gray-500/10 ' +
|
|
indent}
|
|
onclick={() => toggle(root.datatable, sc.schemaKey)}
|
|
>
|
|
{#if multiSelectMode}
|
|
{@const state = batchState(root.datatable, sc.schemaKey)}
|
|
<Checkbox
|
|
checked={state.checked}
|
|
indeterminate={state.indeterminate}
|
|
disabled={state.disabled}
|
|
onChange={() => toggleBatch(root.datatable, sc.schemaKey)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
class="shrink-0"
|
|
/>
|
|
{/if}
|
|
<div class="relative shrink-0 h-8 w-3.5 flex items-center justify-center">
|
|
<FolderIcon class={rowIconClass(!multiSelectMode)} size={14} />
|
|
{#if !multiSelectMode}
|
|
<DropdownV2
|
|
enableFlyTransition
|
|
items={() => [
|
|
{
|
|
displayName: 'Permissions',
|
|
icon: KeyRoundIcon,
|
|
action: () => (schemaPermissionsOpen = true)
|
|
}
|
|
]}
|
|
class={rowActionsClass}
|
|
btnId={'db-manager-schema-actions-' + onlyAlphaNumAndUnderscore(sc.schemaKey)}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
<span class="truncate text-ellipsis grow text-left text-xs">{sc.schemaKey}</span>
|
|
<div class="shrink-0 w-6 h-8 flex items-center justify-end mr-2">
|
|
<ChevronDownIcon class={rowChevronClass(schemaOpen)} size={14} />
|
|
</div>
|
|
</button>
|
|
{/if}
|
|
{#if schemaOpen || !dbSupportsSchemas}
|
|
{@const tableIndent = dbSupportsSchemas
|
|
? root.datatable !== undefined
|
|
? 'pl-11'
|
|
: 'pl-7'
|
|
: root.datatable !== undefined
|
|
? 'pl-7'
|
|
: 'pl-3'}
|
|
{#each sc.tables as tableKey (tableKey)}
|
|
{@const entry = {
|
|
datatable: root.datatable,
|
|
schema: sc.schemaKey,
|
|
table: tableKey
|
|
}}
|
|
{@const hasMenu = !multiSelectMode}
|
|
{@const isSelected =
|
|
root.datatable === currentDatatable &&
|
|
selected.schemaKey === sc.schemaKey &&
|
|
selected.tableKey === tableKey}
|
|
<button
|
|
class={'group w-full text-xs font-normal text-primary flex gap-2 items-center h-8 cursor-pointer pr-1 ' +
|
|
tableIndent +
|
|
' ' +
|
|
(isSelected ? 'bg-surface-secondary' : 'hover:bg-surface-hover')}
|
|
onclick={() => selectTable(root.datatable, sc.schemaKey, tableKey)}
|
|
>
|
|
{#if multiSelectMode}
|
|
<Checkbox
|
|
checked={isTableSelected(entry) || isTableDisabled(entry)}
|
|
disabled={isTableDisabled(entry)}
|
|
title={isTableDisabled(entry) ? 'Already added' : undefined}
|
|
onChange={() => toggleTableSelection(entry)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
class="shrink-0"
|
|
/>
|
|
{/if}
|
|
<div class="relative shrink-0 h-8 w-3.5 flex items-center justify-center">
|
|
<Table2 class={rowIconClass(hasMenu)} size={14} />
|
|
{#if hasMenu}
|
|
<DropdownV2
|
|
enableFlyTransition
|
|
items={() => [
|
|
{
|
|
displayName: 'Delete table',
|
|
icon: Trash2Icon,
|
|
action: () => startDeleteTable(root.datatable, sc.schemaKey, tableKey)
|
|
},
|
|
{
|
|
displayName: 'Alter table',
|
|
icon: EditIcon,
|
|
action: () => startAlterTable(root.datatable, sc.schemaKey, tableKey)
|
|
}
|
|
]}
|
|
class={rowActionsClass}
|
|
btnId={'db-manager-table-actions-' + onlyAlphaNumAndUnderscore(tableKey)}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
<p class="db-manager-table-key truncate text-ellipsis grow text-left text-xs">
|
|
{tableKey}
|
|
</p>
|
|
{#if asset}
|
|
<div class="shrink-0 w-6 h-8 flex items-center justify-end mr-1">
|
|
<Star
|
|
kind="asset"
|
|
path={tableAssetPath(root.datatable, sc.schemaKey, tableKey)}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
<button
|
|
class={'w-full text-xs font-normal flex gap-2 items-center h-8 cursor-pointer pr-1 hover:bg-gray-500/10 text-secondary ' +
|
|
tableIndent}
|
|
onclick={() => startCreateTable(root.datatable, sc.schemaKey)}
|
|
>
|
|
<Plus class="shrink-0" size={14} />
|
|
<span class="text-xs">New table</span>
|
|
</button>
|
|
{/if}
|
|
{/each}
|
|
{#if dbSupportsSchemas && search.trim() === ''}
|
|
<button
|
|
class={'w-full text-xs font-normal flex gap-2 items-center h-8 cursor-pointer pr-1 hover:bg-gray-500/10 text-secondary ' +
|
|
(root.datatable !== undefined ? 'pl-7' : 'pl-3')}
|
|
onclick={() => startCreateSchema(root.datatable)}
|
|
>
|
|
<Plus class="shrink-0" size={14} />
|
|
<span class="text-xs">New schema</span>
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
</Pane>
|
|
<Pane class="p-3 pt-1">
|
|
{#if tableKey && colDefs?.[tableKey]?.length}
|
|
{@const dbTableOps = dbTableOpsFactory({ colDefs: colDefs[tableKey], tableKey })}
|
|
<DBTable {dbTableOps} bind:this={_dbTable} />
|
|
{:else if databaseIsEmpty}
|
|
<div class="h-full w-full center-center flex-col gap-4">
|
|
<span class="text-hint">Database is empty</span>
|
|
{#if onImport}
|
|
<div class="flex gap-4">
|
|
<button
|
|
onclick={() => onImport('schema_only')}
|
|
class="hover:opacity-70 transition-opacity rounded-md border aspect-square w-52 gap-4 p-4 center-center flex-col"
|
|
>
|
|
<UploadIcon size={64} class="text-secondary" />
|
|
<span class="text-center font-normal text-sm text-secondary">
|
|
Import schema from database
|
|
</span>
|
|
</button>
|
|
{#if !!$userStore?.is_admin || !!$superadmin}
|
|
<button
|
|
onclick={() => onImport('schema_and_data')}
|
|
class="hover:opacity-70 transition-opacity rounded-md border aspect-square w-52 gap-4 p-4 center-center flex-col"
|
|
>
|
|
<UploadIcon size={64} class="text-secondary" />
|
|
<span class="text-center font-normal text-sm text-secondary">
|
|
Import schema and data from database
|
|
</span>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</Pane>
|
|
</Splitpanes>
|
|
|
|
<Portal>
|
|
<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 ?? (() => {})}
|
|
/>
|
|
</Portal>
|
|
|
|
<Drawer
|
|
size="600px"
|
|
open={dbTableEditorState.open}
|
|
on:close={() => (dbTableEditorState = { open: false })}
|
|
>
|
|
<DrawerContent
|
|
id="db-table-editor-drawer"
|
|
on:close={() => (dbTableEditorState = { open: false })}
|
|
title={dbTableEditorState.alterTableKey
|
|
? `Alter ${dbTableEditorState.alterTableKey}`
|
|
: 'Create a new table'}
|
|
>
|
|
{#key dbTableEditorState.alterTableKey}
|
|
{#if !dbTableEditorState.alterTableKey || dbTableEditorAlterTableData.current}
|
|
<DbTableEditor
|
|
{features}
|
|
{dbSchema}
|
|
currentSchema={selected.schemaKey}
|
|
initialValues={dbTableEditorAlterTableData.current}
|
|
onConfirm={async ({ values }) => {
|
|
if (dbTableEditorState.alterTableKey && dbTableEditorAlterTableData.current) {
|
|
let diff = diffTableEditorValues(dbTableEditorAlterTableData.current, values)
|
|
// Reverse diff (new → old) so the migration's down undoes the alter.
|
|
let reverse = diffTableEditorValues(values, dbTableEditorAlterTableData.current)
|
|
await dbSchemaOps.onAlter({ schema: selected.schemaKey, values: diff, reverse })
|
|
} else {
|
|
await dbSchemaOps.onCreate({ values, schema: selected.schemaKey })
|
|
}
|
|
refresh?.()
|
|
sendUserToast(
|
|
dbTableEditorState.alterTableKey
|
|
? dbTableEditorState.alterTableKey + ' updated!'
|
|
: values.name + ' created!'
|
|
)
|
|
dbTableEditorState = { open: false }
|
|
}}
|
|
{dbType}
|
|
computePreview={async ({ values }) => {
|
|
if (dbTableEditorState.alterTableKey && dbTableEditorAlterTableData.current) {
|
|
let diff = diffTableEditorValues(dbTableEditorAlterTableData.current, values)
|
|
let sql = await dbSchemaOps.previewAlterSql({
|
|
values: diff,
|
|
schema: selected.schemaKey
|
|
})
|
|
let alert = !dbSupportsTransactionalDdl(dbType)
|
|
? {
|
|
title: capitalize(dbType) + ' does not support transactional DDL',
|
|
body: 'Any of these statements failing may leave your database in an intermediate state.'
|
|
}
|
|
: undefined
|
|
return { sql, ...(alert ? { alert } : {}) }
|
|
} else {
|
|
let sql = await dbSchemaOps.previewCreateSql({ values, schema: selected.schemaKey })
|
|
return { sql }
|
|
}
|
|
}}
|
|
computeBtnProps={({ values }) => {
|
|
if (dbTableEditorState.alterTableKey && dbTableEditorAlterTableData.current) {
|
|
let diff = diffTableEditorValues(dbTableEditorAlterTableData.current, values)
|
|
if (!diff.operations.length) {
|
|
return { text: 'No changes detected', disabled: true }
|
|
}
|
|
return {
|
|
text: `Alter table (${pluralize(diff.operations.length, 'change')} detected)`
|
|
}
|
|
} else {
|
|
return { text: 'Create table' }
|
|
}
|
|
}}
|
|
/>
|
|
{:else if dbTableEditorAlterTableData.loading || !colDefs}
|
|
<Loader2 class="animate-spin" size={32} />
|
|
{:else}
|
|
<p class="text-sm text-tertiary">Failed to load table definition.</p>
|
|
<p>{dbTableEditorAlterTableData.error}</p>
|
|
{/if}
|
|
{/key}
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<Drawer
|
|
size="400px"
|
|
open={newSchemaDialogOpen}
|
|
on:close={() => {
|
|
newSchemaDialogOpen = false
|
|
newSchemaName = ''
|
|
}}
|
|
>
|
|
<DrawerContent
|
|
on:close={() => {
|
|
newSchemaDialogOpen = false
|
|
newSchemaName = ''
|
|
}}
|
|
title="Create a new schema"
|
|
>
|
|
<div class="flex flex-col gap-4">
|
|
<div>
|
|
<label for="schema-name" class="block text-sm font-medium text-primary mb-1"
|
|
>Schema name</label
|
|
>
|
|
<ClearableInput
|
|
bind:value={newSchemaName}
|
|
placeholder="Enter schema name..."
|
|
autofocus
|
|
on:keydown={(e) => {
|
|
if (e.key === 'Enter' && sanitizedNewSchemaName && !schemaAlreadyExists) {
|
|
askingForConfirmation = {
|
|
confirmationText: `Create ${sanitizedNewSchemaName}`,
|
|
type: 'reload',
|
|
title: `This will run 'CREATE SCHEMA ${sanitizedNewSchemaName}' on your database. Are you sure?`,
|
|
open: true,
|
|
id: 'db-create-schema-confirmation-modal',
|
|
onConfirm: async () => {
|
|
askingForConfirmation && (askingForConfirmation.loading = true)
|
|
try {
|
|
await dbSchemaOps.onCreateSchema({ schema: sanitizedNewSchemaName })
|
|
refresh?.()
|
|
selected.schemaKey = sanitizedNewSchemaName
|
|
newSchemaDialogOpen = false
|
|
newSchemaName = ''
|
|
} finally {
|
|
askingForConfirmation = undefined
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
{#if schemaAlreadyExists}
|
|
<p class="text-xs text-red-500 mt-1">
|
|
Schema "{sanitizedNewSchemaName}" already exists
|
|
</p>
|
|
{:else}
|
|
<p class="text-xs text-tertiary mt-1">
|
|
Only letters, numbers, and underscores are allowed.
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{#snippet actions()}
|
|
<Button
|
|
color="blue"
|
|
disabled={!sanitizedNewSchemaName || schemaAlreadyExists}
|
|
on:click={() => {
|
|
askingForConfirmation = {
|
|
confirmationText: `Create ${sanitizedNewSchemaName}`,
|
|
type: 'reload',
|
|
title: `This will run 'CREATE SCHEMA ${sanitizedNewSchemaName}' on your database. Are you sure?`,
|
|
open: true,
|
|
id: 'db-create-schema-confirmation-modal',
|
|
onConfirm: async () => {
|
|
askingForConfirmation && (askingForConfirmation.loading = true)
|
|
try {
|
|
await dbSchemaOps.onCreateSchema({ schema: sanitizedNewSchemaName })
|
|
refresh?.()
|
|
selected.schemaKey = sanitizedNewSchemaName
|
|
newSchemaDialogOpen = false
|
|
newSchemaName = ''
|
|
} finally {
|
|
askingForConfirmation = undefined
|
|
}
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
Create schema
|
|
</Button>
|
|
{/snippet}
|
|
</DrawerContent>
|
|
</Drawer>
|