mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-04 08:02:23 +00:00
* feat: edit variables, resources and triggers in their own session tab Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: take a trigger's flow kind from its config and name the written trigger Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: one operating-workspace context for editors acting on a session's workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: reload page item tabs only in sessions acting on the tool's workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: resolve open in workspace for every preview tab kind in one place Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: guard every component under a session editor against the navigation workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: show a page item tab whose item is gone instead of mounting its editor Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: follow a saved page item in the tab's own workspace and restore in-frame rows Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: close a page item tab from its editor header Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: judge trigger permissions by the user acting in the session workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: judge every session-editor permission by the user acting in that workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep session permissions reactive to the acting role and restore tabs as last seen Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: default Path's owner controls to the user acting in its workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
318 lines
9.9 KiB
Svelte
318 lines
9.9 KiB
Svelte
<script lang="ts" module>
|
|
import type { ColDef, ITooltipComp, ITooltipParams } from 'ag-grid-community'
|
|
import type { TableEditorForeignKey } from './apps/components/display/dbtable/tableEditor'
|
|
import { mount, unmount } from 'svelte'
|
|
import DbForeignKeyTooltip from './DbForeignKeyTooltip.svelte'
|
|
|
|
export type DbRowFilter = { column: string; value: unknown }
|
|
export type DbForeignKeyTarget = { table: string; column: string; value: unknown }
|
|
|
|
type FkTooltipValue = { table: string; column: string; value: unknown }
|
|
|
|
/** AG Grid tooltip hosting the "Go to row" popover of a foreign-keyed cell. */
|
|
class ForeignKeyTooltip implements ITooltipComp {
|
|
private eGui = document.createElement('div')
|
|
private component: ReturnType<typeof mount> | undefined
|
|
|
|
init(params: ITooltipParams & { onGoToRow: (target: DbForeignKeyTarget) => void }) {
|
|
const target = params.value as FkTooltipValue
|
|
this.component = mount(DbForeignKeyTooltip, {
|
|
target: this.eGui,
|
|
props: {
|
|
targetTable: target.table,
|
|
targetColumn: target.column,
|
|
onGoToRow: () => {
|
|
params.hideTooltipCallback?.()
|
|
params.onGoToRow(target)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
getGui() {
|
|
return this.eGui
|
|
}
|
|
destroy() {
|
|
if (this.component) unmount(this.component)
|
|
}
|
|
}
|
|
|
|
const hasForeignKeyValue = (value: unknown) =>
|
|
value !== null && value !== undefined && value !== ''
|
|
|
|
/** Single-column foreign keys only: a composite key has no one cell value to follow. */
|
|
function foreignKeyColDefs(
|
|
foreignKeys: TableEditorForeignKey[],
|
|
onGoToRow: (target: DbForeignKeyTarget) => void
|
|
): Record<string, Partial<ColDef>> {
|
|
const out: Record<string, Partial<ColDef>> = {}
|
|
for (const fk of foreignKeys) {
|
|
if (fk.columns.length !== 1) continue
|
|
const { sourceColumn, targetColumn } = fk.columns[0]
|
|
if (!sourceColumn || !targetColumn || !fk.targetTable) continue
|
|
const table = fk.targetTable
|
|
out[sourceColumn] = {
|
|
tooltipComponent: ForeignKeyTooltip,
|
|
tooltipComponentParams: { onGoToRow },
|
|
tooltipValueGetter: (p): FkTooltipValue | null =>
|
|
hasForeignKeyValue(p.value) ? { table, column: targetColumn, value: p.value } : null,
|
|
cellClass: (p) =>
|
|
hasForeignKeyValue(p.value)
|
|
? 'underline decoration-dotted decoration-tertiary underline-offset-2'
|
|
: ''
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { createGrid, type GridApi, type IDatasource } from 'ag-grid-community'
|
|
import { transformColumnDefs } from './apps/components/display/table/utils'
|
|
import DarkModeObserver from './DarkModeObserver.svelte'
|
|
import { Badge, Button } from './common'
|
|
import { Download, X } from 'lucide-svelte'
|
|
import Popover from './Popover.svelte'
|
|
import DebouncedInput from './apps/components/helpers/DebouncedInput.svelte'
|
|
import InsertRowDrawerButton from './apps/components/display/InsertRowDrawerButton.svelte'
|
|
import type { IDbTableOps } from './dbOps'
|
|
import { deepEqual } from 'fast-equals'
|
|
import 'ag-grid-community/styles/ag-grid.css'
|
|
import 'ag-grid-community/styles/ag-theme-alpine.css'
|
|
import '$lib/components/apps/components/display/table/theme/windmill-theme.css'
|
|
import { untrack } from 'svelte'
|
|
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
|
|
|
const operatingWorkspace = useOperatingWorkspace()
|
|
|
|
type Props = {
|
|
dbTableOps: IDbTableOps
|
|
/** Foreign keys of the displayed table; their cells get a "Go to row" popover. */
|
|
foreignKeys?: TableEditorForeignKey[]
|
|
onGoToRow?: (target: DbForeignKeyTarget) => void
|
|
/** Equality filter already applied by `dbTableOps`, shown as a dismissable chip. */
|
|
rowFilter?: DbRowFilter
|
|
onClearRowFilter?: () => void
|
|
}
|
|
let { dbTableOps, foreignKeys, onGoToRow, rowFilter, onClearRowFilter }: Props = $props()
|
|
|
|
let [clientHeight, clientWidth, darkMode, firstRow, lastRow] = $state([0, 0, false, -1, -1])
|
|
let quicksearch = $state('')
|
|
let api: GridApi<any> | undefined = $state()
|
|
let eGui: HTMLDivElement | undefined = $state()
|
|
|
|
let datasource: IDatasource = {
|
|
getRows: async function (params) {
|
|
if (!$operatingWorkspace) return params.failCallback()
|
|
let lastRow = rowCount && rowCount <= params.endRow ? rowCount : -1
|
|
|
|
const items = await dbTableOps.getRows({
|
|
offset: params.startRow,
|
|
limit: params.endRow - params.startRow,
|
|
quicksearch: params.context.quicksearch,
|
|
order_by: params.sortModel?.[0]?.colId ?? dbTableOps.colDefs[0]?.field,
|
|
is_desc: params.sortModel?.[0]?.sort === 'desc'
|
|
})
|
|
|
|
if (items.length < params.endRow - params.startRow) lastRow = params.startRow + items.length
|
|
params.successCallback(items, lastRow)
|
|
}
|
|
}
|
|
let rowCount = $state(0)
|
|
let refreshCount = $state(0)
|
|
export const refresh = () => (refreshCount += 1)
|
|
|
|
$effect(() => eGui && untrack(() => mountGrid()))
|
|
function mountGrid() {
|
|
if (eGui && !api) {
|
|
createGrid(eGui, {
|
|
rowModelType: 'infinite',
|
|
pagination: false,
|
|
...(dbTableOps.onUpdate && {
|
|
defaultColDef: {
|
|
flex: 1,
|
|
minWidth: 150,
|
|
editable: true,
|
|
onCellValueChanged: (e) => {
|
|
if (!$operatingWorkspace) return
|
|
const colDef = e.colDef as unknown as { field: string; datatype: string }
|
|
dbTableOps
|
|
.onUpdate?.(
|
|
{ values: { ...(e.data as object), [colDef.field]: e.oldValue } },
|
|
colDef,
|
|
e.newValue
|
|
)
|
|
.then(() => {
|
|
sendUserToast('Value updated')
|
|
})
|
|
.catch((e) => {
|
|
sendUserToast('Error updating value: ' + ((e as Error)?.message || e), true)
|
|
refresh?.()
|
|
})
|
|
}
|
|
}
|
|
}),
|
|
onViewportChanged: (e) => ([firstRow, lastRow] = [e.firstRow, e.lastRow]),
|
|
// Lets the pointer move from a foreign-keyed cell into its "Go to row" popover.
|
|
tooltipInteraction: true,
|
|
// AG Grid floors the show delay at 200ms whatever value is given.
|
|
tooltipShowDelay: 0,
|
|
cacheBlockSize: 100,
|
|
cacheOverflowSize: 10,
|
|
maxBlocksInCache: 20,
|
|
suppressColumnMoveAnimation: true,
|
|
suppressDragLeaveHidesColumns: true,
|
|
onGridReady: (e) => {
|
|
api = e.api
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
let prevUpdateKey: any = undefined
|
|
$effect(() => {
|
|
if (!$operatingWorkspace || !api) return
|
|
const key = { quicksearch, colDefs: dbTableOps.colDefs, refreshCount, rowFilter }
|
|
if (deepEqual(key, prevUpdateKey)) return
|
|
prevUpdateKey = key
|
|
untrack(() => updateGrid())
|
|
})
|
|
// Foreign keys arrive after the rows: refresh the column defs alone so the
|
|
// popovers appear without re-running the row and count queries.
|
|
let prevForeignKeys: TableEditorForeignKey[] | undefined = undefined
|
|
$effect(() => {
|
|
if (!api || foreignKeys === prevForeignKeys) return
|
|
prevForeignKeys = foreignKeys
|
|
untrack(() => api?.updateGridOptions({ columnDefs: buildColumnDefs() }))
|
|
})
|
|
function buildColumnDefs() {
|
|
const fkColDefs = foreignKeys && onGoToRow ? foreignKeyColDefs(foreignKeys, onGoToRow) : {}
|
|
return transformColumnDefs({
|
|
columnDefs: (dbTableOps.colDefs ?? []).map((c) =>
|
|
c.field && fkColDefs[c.field] ? { ...c, ...fkColDefs[c.field] } : c
|
|
),
|
|
...(dbTableOps.onDelete && {
|
|
onDelete: (values) => {
|
|
if (!$operatingWorkspace) return
|
|
dbTableOps
|
|
.onDelete?.({ values })
|
|
.then(() => {
|
|
refresh?.()
|
|
sendUserToast('Row deleted')
|
|
})
|
|
.catch((e) => {
|
|
sendUserToast(`Error deleting row: ${e?.message ?? e}`, true)
|
|
})
|
|
}
|
|
})
|
|
})
|
|
}
|
|
function updateGrid() {
|
|
dbTableOps.getCount({ quicksearch }).then((result) => (rowCount = result))
|
|
|
|
api?.purgeInfiniteCache()
|
|
api?.updateGridOptions({
|
|
datasource,
|
|
columnDefs: buildColumnDefs(),
|
|
context: {
|
|
quicksearch
|
|
}
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<DarkModeObserver bind:darkMode />
|
|
|
|
<div class="h-full relative flex flex-col">
|
|
<div class="flex py-2 h-12 justify-between gap-4">
|
|
<div class="flex w-full items-center gap-2">
|
|
<DebouncedInput
|
|
class="w-full max-w-[300px]"
|
|
type="text"
|
|
bind:value={quicksearch}
|
|
placeholder="Search..."
|
|
/>
|
|
{#if rowFilter}
|
|
<Badge rounded class="pr-0.5 text-xs" data-testid="db-row-filter-chip">
|
|
<span class="font-mono">{rowFilter.column} = {String(rowFilter.value)}</span>
|
|
<Button
|
|
iconOnly
|
|
unifiedSize="2xs"
|
|
variant="subtle"
|
|
startIcon={{ icon: X }}
|
|
title="Clear filter"
|
|
onClick={onClearRowFilter}
|
|
/>
|
|
</Badge>
|
|
{/if}
|
|
</div>
|
|
{#if dbTableOps.onInsert}
|
|
<InsertRowDrawerButton
|
|
columnDefs={dbTableOps.colDefs ?? []}
|
|
dbType={dbTableOps.dbType}
|
|
onInsert={(values) => {
|
|
if (!$operatingWorkspace) return
|
|
dbTableOps.onInsert?.({ values }).then((result) => {
|
|
refresh?.()
|
|
sendUserToast('Row inserted')
|
|
})
|
|
}}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
<div
|
|
class={'flex flex-col flex-1 component-wrapper divide-y wm-aggrid-container'}
|
|
bind:clientHeight
|
|
bind:clientWidth
|
|
>
|
|
<div
|
|
style:height="{clientHeight}px"
|
|
style:width="{clientWidth}px"
|
|
class="ag-theme-alpine"
|
|
class:ag-theme-alpine-dark={darkMode}
|
|
>
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
bind:this={eGui}
|
|
class={api ? '' : 'opacity-0'}
|
|
style:height="100%"
|
|
onkeydown={(e) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
|
const selectedCell = api?.getFocusedCell()
|
|
if (selectedCell) {
|
|
const rowIndex = selectedCell.rowIndex
|
|
const colId = selectedCell.column?.getId()
|
|
const rowNode = api?.getDisplayedRowAtIndex(rowIndex)
|
|
const selectedValue = rowNode?.data?.[colId]
|
|
navigator.clipboard.writeText(selectedValue)
|
|
sendUserToast('Copied cell value to clipboard', false)
|
|
}
|
|
}
|
|
}}
|
|
></div>
|
|
</div>
|
|
|
|
<div class="flex gap-1 w-full justify-between items-center text-xs text-primary p-2">
|
|
<div>
|
|
<Popover>
|
|
{#snippet text()}
|
|
Download
|
|
{/snippet}
|
|
<Button
|
|
startIcon={{ icon: Download }}
|
|
color="light"
|
|
size="xs2"
|
|
on:click={() => api?.exportDataAsCsv()}
|
|
iconOnly
|
|
/>
|
|
</Popover>
|
|
</div>
|
|
{#if rowCount}
|
|
{firstRow}{'->'}{lastRow + 1} of {rowCount} rows
|
|
{:else}
|
|
{firstRow}{'->'}{lastRow + 1}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|