feat(datatables): switch a data table's role from its row badge

The badge is now a button with a chevron, opening the select dropdown over
the roles the caller may use — the dropdown alone, not Select's own input.
Picking one reconnects the manager as that role, switching data table first
when the badge belongs to another one.
This commit is contained in:
Diego Imbert
2026-08-31 04:03:42 +02:00
parent bebd8bef8b
commit 7570349f2e
5 changed files with 92 additions and 12 deletions
+21 -10
View File
@@ -34,7 +34,7 @@
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 DatatableRoleBadge from './DatatableRoleBadge.svelte'
import type { Asset, DataTableTables } from '$lib/gen'
import { ADMIN_DATATABLE_ROLE, type DatatableRowAction } from './dbTypes'
import TextInput from './text_input/TextInput.svelte'
@@ -79,6 +79,8 @@
onSelectDatatable?: (datatable: string) => void
/** Role the manager is connected as, when it is not the default one. */
currentRole?: string
/** Switch a data table's role from its row badge. */
onSelectRole?: (datatable: string, role: string) => void
pendingAction?: PendingRowAction | undefined
/** Row-menu actions on a data table, run against that row's data table. */
onDatatableAction?: (datatable: string, action: DatatableRowAction) => void
@@ -110,6 +112,7 @@
datatableTreeLoading,
onSelectDatatable,
currentRole,
onSelectRole,
pendingAction = $bindable(undefined),
onDatatableAction,
canManageDatatable = false,
@@ -211,16 +214,18 @@
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 {
/** The role a data table row is reached through, and what it can be switched
* to. Absent where naming the role says nothing: a data table without
* permissions, or one whose single usable role is already the implicit
* `admin`. */
function roleOf(datatable: string): { role: string; roles: 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)) {
const roles = entry?.usable_roles ?? []
if (roles.length === 0 || (roles.length === 1 && roles[0] === ADMIN_DATATABLE_ROLE)) {
return undefined
}
return (datatable === currentDatatable ? currentRole : undefined) ?? entry?.default_role
const role = (datatable === currentDatatable ? currentRole : undefined) ?? entry?.default_role
return role ? { role, roles } : undefined
}
const matchesSearch = (t: string) => t.toLowerCase().includes(search.trim().toLowerCase())
@@ -504,6 +509,7 @@
{@const dtOpen = isExpanded(root.datatable)}
{#if root.datatable !== undefined}
{@const hasMenu = !multiSelectMode && onDatatableAction !== undefined}
{@const roleInfo = roleOf(root.datatable)}
<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)}
@@ -557,8 +563,13 @@
{/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 roleInfo}
{@const dt = root.datatable}
<DatatableRoleBadge
role={roleInfo.role}
roles={roleInfo.roles}
onSelect={(role) => onSelectRole?.(dt, role)}
/>
{/if}
<div class="grow"></div>
<div class="shrink-0 w-6 h-8 flex items-center justify-end mr-2">
@@ -43,6 +43,7 @@
datatableTree?: DataTableTables[]
datatableTreeLoading?: boolean
onSelectDatatable?: (datatable: string) => void
onSelectRole?: (datatable: string, role: string) => void
pendingAction?: PendingRowAction | undefined
onDatatableAction?: (datatable: string, action: DatatableRowAction) => void
canManageDatatable?: boolean
@@ -71,6 +72,7 @@
datatableTree,
datatableTreeLoading,
onSelectDatatable,
onSelectRole,
pendingAction = $bindable(undefined),
onDatatableAction,
canManageDatatable,
@@ -325,6 +327,7 @@
{datatableTree}
{datatableTreeLoading}
{onSelectDatatable}
{onSelectRole}
currentRole={input.type === 'database' ? input.role : undefined}
bind:pendingAction
{onDatatableAction}
@@ -257,6 +257,11 @@
datatableTree={uriState.isDatatableInput ? datatables.current : undefined}
datatableTreeLoading={datatables.loading}
onSelectDatatable={(dt) => (uriState.selectedDatatable = dt)}
onSelectRole={(dt, role) => {
// Setting the data table clears the role, so the order matters.
uriState.selectedDatatable = dt
uriState.selectedRole = role
}}
bind:pendingAction
canManageDatatable={!!($superadmin || $userStore?.is_admin)}
onDatatableAction={runDatatableAction}
@@ -0,0 +1,57 @@
<script lang="ts">
import { ChevronDown } from 'lucide-svelte'
import SelectDropdown from './select/SelectDropdown.svelte'
import { clickOutside } from '$lib/utils'
let {
role,
roles,
onSelect
}: {
/** The role in effect, shown on the badge. */
role: string
/** The roles the caller may switch to. */
roles: string[]
onSelect: (role: string) => void
} = $props()
let open = $state(false)
let btnEl: HTMLButtonElement | undefined = $state()
const items = $derived(roles.map((r) => ({ label: r, value: r })))
// The table picker's drawer opens at `disposables + 10000`, which the
// dropdown's own z-index would sit under.
const dropdownClass = 'z-[20000]'
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span class="relative flex" use:clickOutside={{ onClickOutside: () => (open = false) }}>
<button
bind:this={btnEl}
type="button"
class="flex items-center gap-0.5 rounded-md pl-2 pr-1 py-0.5 text-2xs whitespace-nowrap
bg-surface-sunken text-primary transition-[filter,transform]
hover:brightness-95 active:brightness-90 active:scale-[0.97]
{open ? 'brightness-95' : ''}"
onclick={(e) => {
// The row underneath folds on click, and picking a role is not that.
e.stopPropagation()
open = !open
}}
>
{role}
<ChevronDown size={11} class="text-secondary transition-transform {open ? 'rotate-180' : ''}" />
</button>
<SelectDropdown
processedItems={items}
value={role}
{open}
listAutoWidth={false}
class={dropdownClass}
getInputRect={btnEl && (() => btnEl!.getBoundingClientRect())}
onSelectValue={(item) => {
open = false
if (item.value !== role) onSelect(item.value)
}}
/>
</span>
@@ -30,6 +30,8 @@
let open = $state(false)
let selectedDatatable = $state<string | undefined>(undefined)
/** Role the query editor connects as; undefined means the data table's default. */
let selectedRole = $state<string | undefined>(undefined)
// For DB manager
let dbManagerContent: DBManagerContent | undefined = $state()
@@ -125,6 +127,7 @@
type: 'database' as const,
resourceType: 'postgresql' as const,
resourcePath: `datatable://${selectedDatatable}`,
role: selectedRole,
specificSchema: selectedSchemaKey,
specificTable: selectedTableKey
}
@@ -169,7 +172,7 @@
noPadding
>
{#if dbInput && opWs}
{#key selectedDatatable}
{#key `${selectedDatatable}~${selectedRole ?? ''}`}
<DBManagerContent
bind:this={dbManagerContent}
input={dbInput}
@@ -183,7 +186,8 @@
{disabledTables}
datatableTree={datatableTree.current}
datatableTreeLoading={datatableTree.loading}
onSelectDatatable={(dt) => (selectedDatatable = dt)}
onSelectDatatable={(dt) => ((selectedDatatable = dt), (selectedRole = undefined))}
onSelectRole={(dt, role) => ((selectedDatatable = dt), (selectedRole = role))}
bind:pendingAction
/>
{/key}