fix: improve aggrid actions column (#6600)

* aggrid improvements actions column

* cleanup

* header preference

* force redraw

* simplify types

* reactivity

* fixing potential race condition
This commit is contained in:
Alexander Petric
2025-09-13 10:15:14 +00:00
committed by GitHub
parent 8e26315b2c
commit 6b1f36f47e
6 changed files with 344 additions and 173 deletions
@@ -40,7 +40,7 @@
import ResolveStyle from '../../helpers/ResolveStyle.svelte'
import AppAggridTableActions from './AppAggridTableActions.svelte'
import { cellRendererFactory, defaultCellRenderer } from './utils'
import { cellRendererFactory, transformColumnDefs, type WindmillColumnDef } from './utils'
import Popover from '$lib/components/Popover.svelte'
import { Button } from '$lib/components/common'
import InputValue from '../../helpers/InputValue.svelte'
@@ -303,6 +303,12 @@
taComponent.props.rowIndex = params.node.rowIndex ?? 0
taComponent.props.row = params.data
taComponent.props.p = params
const nextActions: TableAction[] | undefined = computedOrder
? (computedOrder
.map((key) => actions?.find((a) => a.id === key))
.filter(Boolean) as TableAction[])
: actions
taComponent.props.actions = nextActions
}
}
})
@@ -317,36 +323,25 @@
// console.log(resolvedConfig?.extraConfig)
if (eGui) {
try {
let columnDefs =
Array.isArray(resolvedConfig?.columnDefs) && resolvedConfig.columnDefs.every(isObject)
? [...resolvedConfig?.columnDefs] // Clone to avoid direct mutation
: []
// Add the action column if actions are defined
if (actions && actions.length > 0) {
columnDefs.push({
headerName: resolvedConfig?.customActionsHeader
? resolvedConfig?.customActionsHeader
: 'Actions',
cellRenderer: tableActionsFactory,
autoHeight: true,
cellStyle: { textAlign: 'center' },
cellClass: 'grid-cell-centered',
...(!resolvedConfig?.wrapActions ? { minWidth: 130 * actions?.length } : {})
})
}
const agColumnDefs = transformColumnDefs({
columnDefs:
Array.isArray(resolvedConfig?.columnDefs) && resolvedConfig.columnDefs.every(isObject)
? [...resolvedConfig?.columnDefs] as WindmillColumnDef[]
: [],
actions,
customActionsHeader: resolvedConfig?.customActionsHeader,
wrapActions: resolvedConfig?.wrapActions,
tableActionsFactory,
onInvalidColumnDefs: (errors) => {
sendUserToast(`Invalid columnDefs: ${errors.join('\n')}`, true)
}
})
createGrid(
eGui,
{
rowData: value,
columnDefs: columnDefs.map((fields) => {
let cr = defaultCellRenderer(fields.cellRendererType)
return {
...fields,
...(cr ? { cellRenderer: cr } : {})
}
}),
columnDefs: agColumnDefs,
pagination: resolvedConfig?.pagination,
paginationAutoPageSize: resolvedConfig?.pagination,
suppressPaginationPanel: true,
@@ -476,34 +471,23 @@
function updateOptions() {
try {
const columnDefs =
Array.isArray(resolvedConfig?.columnDefs) && resolvedConfig.columnDefs.every(isObject)
? [...resolvedConfig?.columnDefs] // Clone to avoid direct mutation
: []
// Add the action column if actions are defined
if (actions && actions.length > 0) {
columnDefs.push({
headerName: resolvedConfig?.customActionsHeader
? resolvedConfig?.customActionsHeader
: 'Actions',
cellRenderer: tableActionsFactory,
autoHeight: true,
cellStyle: { textAlign: 'center' },
cellClass: 'grid-cell-centered',
...(!resolvedConfig?.wrapActions ? { minWidth: 130 * actions?.length } : {})
})
}
const agColumnDefs = transformColumnDefs({
columnDefs:
Array.isArray(resolvedConfig?.columnDefs) && resolvedConfig.columnDefs.every(isObject)
? [...resolvedConfig?.columnDefs] as WindmillColumnDef[]
: [],
actions,
customActionsHeader: resolvedConfig?.customActionsHeader,
wrapActions: resolvedConfig?.wrapActions,
tableActionsFactory,
onInvalidColumnDefs: (errors) => {
sendUserToast(`Invalid columnDefs: ${errors.join('\n')}`, true)
}
})
api?.updateGridOptions({
rowData: value,
columnDefs: columnDefs.map((fields) => {
let cr = defaultCellRenderer(fields.cellRendererType)
return {
...fields,
...(cr ? { cellRenderer: cr } : {})
}
}),
columnDefs: agColumnDefs,
pagination: resolvedConfig?.pagination,
paginationAutoPageSize: resolvedConfig?.pagination,
suppressPaginationPanel: true,
@@ -523,6 +507,10 @@
...resolvedConfig?.extraConfig?.['defaultColDef']
}
})
// Force refresh to re-render cell renderers after actions change
api?.refreshCells({ force: true })
// Force complete redraw to clear stale inline styles
api?.redrawRows()
} catch (e) {
console.error(e)
sendUserToast("Couldn't update the grid:" + e, true)
@@ -609,7 +597,13 @@
bind:loading
hideRefreshButton={true}
>
<SyncColumnDefs {id} columnDefs={resolvedConfig.columnDefs} {result}>
<SyncColumnDefs
{id}
columnDefs={resolvedConfig.columnDefs}
{result}
actionsPresent={Array.isArray(actions) && actions.length > 0}
customActionsHeader={resolvedConfig?.customActionsHeader}
>
<div
class={twMerge(
'flex flex-col h-full component-wrapper divide-y',
@@ -7,13 +7,20 @@
import Badge from '$lib/components/common/badge/Badge.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { isObject } from '$lib/utils'
import type { WindmillColumnDef } from './utils'
type ColumnDefsConfiguration =
| { type: 'static'; value: WindmillColumnDef[] }
| { type: 'evalv2'; expr: string }
interface Props {
id: string
columnDefs?: Array<any>
result?: Array<any> | undefined
columnDefs?: WindmillColumnDef[]
result?: Array<Record<string, any>> | undefined
allowColumnDefsActions?: boolean
children?: import('svelte').Snippet
actionsPresent?: boolean
customActionsHeader?: string | undefined
}
let {
@@ -21,31 +28,113 @@
columnDefs = [],
result = [],
allowColumnDefsActions = true,
children
children,
actionsPresent = false,
customActionsHeader = undefined
}: Props = $props()
const { app, mode, selectedComponent } = getContext<AppViewerContext>('AppViewerContext')
let syncInProgress = false
function hasActionsPlaceholder(cols: WindmillColumnDef[] | undefined): boolean {
if (!Array.isArray(cols)) return false
return cols.findIndex((c) => c?._isActionsColumn === true) > -1
}
function addActionsPlaceholder(cols: WindmillColumnDef[] | undefined): WindmillColumnDef[] {
const hdr = customActionsHeader ?? 'Actions'
const placeholder: WindmillColumnDef = {
field: '__actions__',
_isActionsColumn: true,
headerName: hdr,
flex: 1
}
if (!Array.isArray(cols)) return [placeholder]
return [...cols, placeholder]
}
function removeActionsPlaceholder(cols: WindmillColumnDef[] | undefined): WindmillColumnDef[] {
if (!Array.isArray(cols)) return []
return cols.filter((c) => c?._isActionsColumn !== true)
}
async function ensureActionsColumn() {
// Only act in editor (DND) mode
if ($mode !== 'dnd') return
const gridItem = findGridItem($app, id)
if (!gridItem) return
// Type the configuration more safely
const rawConf = gridItem.data.configuration?.columnDefs
if (!rawConf) return
// Type guard for configuration structure
const conf = rawConf as ColumnDefsConfiguration
if (!conf.type || (conf.type !== 'static' && conf.type !== 'evalv2')) return
let currentColumns: WindmillColumnDef[] | undefined
if (conf.type === 'static') {
currentColumns = Array.isArray(conf.value) ? conf.value : []
} else if (conf.type === 'evalv2') {
try {
const parsed = JSON.parse(conf.expr ?? '[]')
currentColumns = Array.isArray(parsed) ? parsed : []
} catch (e) {
console.warn('Failed to parse columnDefs expression:', e)
currentColumns = []
}
}
const hasPlaceholder = hasActionsPlaceholder(currentColumns)
// Auto-sync logic: add missing actions column, remove when actions gone
const needsAdd = actionsPresent && !hasPlaceholder
const needsRemove = !actionsPresent && hasPlaceholder
if (!needsAdd && !needsRemove) return
let nextColumns = currentColumns || []
if (needsAdd) nextColumns = addActionsPlaceholder(nextColumns)
if (needsRemove) nextColumns = removeActionsPlaceholder(nextColumns)
// Update configuration with proper typing
if (conf.type === 'static') {
conf.value = nextColumns
} else if (conf.type === 'evalv2') {
conf.expr = JSON.stringify(nextColumns)
}
await updateConfiguration()
}
$effect(() => {
const shouldSync = actionsPresent !== undefined || columnDefs?.length !== undefined
if (shouldSync && !syncInProgress) {
syncInProgress = true
ensureActionsColumn().finally(() => {
syncInProgress = false
})
}
})
async function syncColumns() {
let gridItem = findGridItem($app, id)
const gridItem = findGridItem($app, id)
if (gridItem && result) {
const keys = Object.keys(result[0] ?? {}) ?? []
const conf = gridItem.data.configuration.columnDefs as ColumnDefsConfiguration
if (gridItem.data.configuration.columnDefs.type === 'static') {
gridItem.data.configuration.columnDefs.value = keys.map((key) => ({
field: key,
headerName: key,
flex: 1
}))
} else if (gridItem.data.configuration.columnDefs.type === 'evalv2') {
gridItem.data.configuration.columnDefs.expr = JSON.stringify(
keys.map((key, index) => ({
field: key,
headerName: key,
flex: 1
}))
)
const newColumns: WindmillColumnDef[] = keys.map((key) => ({
field: key,
headerName: key,
flex: 1
}))
if (conf.type === 'static') {
conf.value = newColumns
} else if (conf.type === 'evalv2') {
conf.expr = JSON.stringify(newColumns)
}
await updateConfiguration()
@@ -53,12 +142,16 @@
}
async function setEmptyColumns() {
let gridItem = findGridItem($app, id)
if (gridItem && gridItem.data.configuration.columnDefs.type === 'static') {
gridItem.data.configuration.columnDefs.value = []
const gridItem = findGridItem($app, id)
if (!gridItem) return
const conf = gridItem.data.configuration.columnDefs as ColumnDefsConfiguration
if (conf.type === 'static') {
conf.value = []
await updateConfiguration()
} else if (gridItem && gridItem.data.configuration.columnDefs.type === 'evalv2') {
gridItem.data.configuration.columnDefs.expr = '[]'
} else if (conf.type === 'evalv2') {
conf.expr = '[]'
await updateConfiguration()
}
}
@@ -3,12 +3,21 @@
* See: https://stackoverflow.com/a/72608215
*/
import type { ColDef, ColGroupDef, ICellRendererComp, ICellRendererParams } from 'ag-grid-community'
import { ColumnIdentity, type ColumnDef } from '../dbtable/utils'
import { ColumnIdentity } from '../dbtable/utils'
import type { TableAction } from '$lib/components/apps/editor/component'
import { mount, unmount } from 'svelte'
import { Button } from '$lib/components/common'
import { Trash2 } from 'lucide-svelte'
export type WindmillColumnDef = ColDef & {
_isActionsColumn?: boolean
cellRendererType?: string
ignored?: boolean
hideInsert?: boolean
isidentity?: ColumnIdentity
children?: WindmillColumnDef[]
}
/**
* Class for defining a cell renderer.
* If you don't need to define a separate class you could use cellRendererFactory
@@ -125,7 +134,7 @@ export function transformColumnDefs({
onDelete,
onInvalidColumnDefs
}: {
columnDefs: ColumnDef[]
columnDefs: WindmillColumnDef[]
actions?: TableAction[]
customActionsHeader?: string
wrapActions?: boolean
@@ -146,6 +155,13 @@ export function transformColumnDefs({
let r: any[] = columnDefs?.filter((x) => x && !x.ignored) ?? []
// Allow an explicit "actions" placeholder in columnDefs so users can manage it like any column.
// When present, replace it with the computed actions colDef. When not present but actions exist,
// we will append the actions column after validation below.
const actionsIndex = r.findIndex((c) => {
return c?._isActionsColumn === true
})
if (onDelete) {
r.push({
field: 'delete',
@@ -189,16 +205,41 @@ export function transformColumnDefs({
}
if (actions?.length) {
r.push({
headerName: customActionsHeader ?? 'Actions',
const computedActionsCol = {
field: '__actions__',
_isActionsColumn: true,
headerName: 'Actions',
cellRenderer: tableActionsFactory,
autoHeight: true,
cellStyle: { textAlign: 'center' },
cellClass: 'grid-cell-centered',
lockPosition: 'right',
// Only lock position to right if user hasn't explicitly positioned the actions column
...(actionsIndex === -1 ? { lockPosition: 'right' } : {}),
// Set default minWidth based on number of actions (if not wrapping)
...(!wrapActions ? { minWidth: 130 * actions?.length } : {}),
// Respect user-specified overrides when placeholder present (these should override defaults)
...(
actionsIndex > -1
? {
// keep width/pin/flex/align/hide from placeholder when provided
...(['width', 'minWidth', 'maxWidth', 'flex', 'pinned', 'headerName', 'cellStyle', 'cellClass', 'autoHeight', 'hide']
.reduce((acc, key) => {
if (r[actionsIndex] && r[actionsIndex][key] !== undefined) acc[key] = r[actionsIndex][key]
return acc
}, {} as any))
}
: {}
),
...(customActionsHeader?.trim() ? { headerName: customActionsHeader } : {})
}
...(!wrapActions ? { minWidth: 130 * actions?.length } : {})
})
if (actionsIndex > -1) {
// Replace the placeholder with computed column
r.splice(actionsIndex, 1, computedActionsCol)
} else {
// Backward compatible: append if not explicitly placed
r.push(computedActionsCol)
}
}
return r.map((fields) => {
@@ -210,7 +251,7 @@ export function transformColumnDefs({
})
}
export function validateColumnDefs(columnDefs: ColumnDef[]): {
export function validateColumnDefs(columnDefs: WindmillColumnDef[]): {
isValid: boolean
errors: string[]
} {
@@ -3,7 +3,7 @@
import { Button } from '$lib/components/common'
import { GripVertical, Loader2, Plus, X } from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, getContext, tick } from 'svelte'
import type { InputType, StaticInput, StaticOptions } from '../../inputType'
import SubTypeEditor from './SubTypeEditor.svelte'
import { dragHandle, dragHandleZone } from '@windmill-labs/svelte-dnd-action'
@@ -11,6 +11,8 @@
import Toggle from '$lib/components/Toggle.svelte'
import QuickAddColumn from './QuickAddColumn.svelte'
import RefreshDatabaseStudioTable from './RefreshDatabaseStudioTable.svelte'
import { findGridItem } from '$lib/components/apps/editor/appUtils'
import type { AppViewerContext } from '$lib/components/apps/types'
interface Props {
componentInput: StaticInput<any[]> & { loading?: boolean }
@@ -26,6 +28,9 @@
id
}: Props = $props()
const appContext = getContext<AppViewerContext>('AppViewerContext')
const { app, selectedComponent } = appContext || {}
let items: ReturnType<typeof getItems> = $state([])
items = getItems(componentInput)
@@ -185,10 +190,34 @@
items = getItems(componentInput)
}
function deleteElementByType(index: number) {
async function updateConfiguration() {
if (selectedComponent && id) {
$selectedComponent = undefined
await tick()
$selectedComponent = [id]
}
}
async function deleteElementByType(index: number) {
if (componentInput.value) {
const item = componentInput.value[index]
// If deleting actions column, clear all table actions
if (subFieldType === 'ag-grid' && item && item._isActionsColumn === true) {
const gridItem = id ? findGridItem($app, id) : null
if (gridItem && (
gridItem.data.type === 'aggridcomponent' ||
gridItem.data.type === 'aggridcomponentee' ||
gridItem.data.type === 'aggridinfinitecomponent' ||
gridItem.data.type === 'aggridinfinitecomponentee'
) && Array.isArray(gridItem.data.actions)) {
gridItem.data.actions.length = 0
}
await updateConfiguration()
return
}
componentInput.value.splice(index, 1)
items.splice(index, 1) // Add this
items.splice(index, 1)
items = items
componentInput.value = componentInput.value
dispatch('deleteArrayItem', { index })
@@ -299,11 +299,19 @@
{:else if fieldType === 'ag-grid'}
<div class="flex flex-row rounded-md bg-surface items-center h-full">
<div class="relative w-full">
<input
class="text-xs px-2 border-y w-full flex flex-row items-center border-r rounded-r-md h-8"
bind:value={componentInput.value.field}
placeholder="Field"
/>
{#if componentInput.value._isActionsColumn === true}
<div
class="text-xs px-2 border w-full flex flex-row items-center rounded-r-md h-8 text-primary"
>
Actions Column
</div>
{:else}
<input
class="text-xs px-2 border-y w-full flex flex-row items-center border-r rounded-r-md h-8"
bind:value={componentInput.value.field}
placeholder="Field"
/>
{/if}
<div class="absolute top-1 right-1">
<AgGridWizard bind:value={componentInput.value}>
{#snippet trigger()}
@@ -35,6 +35,8 @@
}
let { value = $bindable(), trigger: trigger_render }: Props = $props()
const isActionsColumn = $derived((value as any)?._isActionsColumn === true)
const presets = [
{
@@ -123,16 +125,18 @@
<input type="text" placeholder="Header name" bind:value={value.headerName} />
</Label>
<Label label="Editable value">
<Toggle
on:pointerdown={(e) => {
e?.stopPropagation()
}}
options={{ right: 'Editable' }}
bind:checked={value.editable}
size="xs"
/>
</Label>
{#if !isActionsColumn}
<Label label="Editable value">
<Toggle
on:pointerdown={(e) => {
e?.stopPropagation()
}}
options={{ right: 'Editable' }}
bind:checked={value.editable}
size="xs"
/>
</Label>
{/if}
<Label label="Min width (px)">
<input type="number" placeholder="width" bind:value={value.minWidth} />
@@ -155,8 +159,8 @@
</Tooltip>
{/snippet}
<input type="range" step="1" bind:value={value.flex} min={1} max={12} />
<div class="text-xs">{value.flex}</div>
<input type="range" step="1" bind:value={value.flex} min={0} max={12} />
<div class="text-xs">{value.flex ?? 0}</div>
</Label>
<Label label="Hide">
@@ -170,7 +174,8 @@
/>
</Label>
<Label label="Value formatter">
{#if !isActionsColumn}
<Label label="Value formatter">
{#snippet header()}
<Tooltip
documentationLink="https://www.ag-grid.com/javascript-data-grid/value-formatters/"
@@ -228,83 +233,84 @@
{/key}
</div>
<Label label="Sort">
<select bind:value={value.sort}>
<Label label="Sort">
<select bind:value={value.sort}>
<option value={null}>None</option>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
</Label>
<Label label="Filter">
{#snippet header()}
<Tooltip documentationLink="https://www.ag-grid.com/javascript-data-grid/filtering/">
Filtering allows you to limit the rows displayed in your grid to those that match
criteria you specify.
</Tooltip>
{/snippet}
<Toggle
on:pointerdown={(e) => {
e?.stopPropagation()
}}
options={{ right: 'Enable filter' }}
bind:checked={value.filter}
size="xs"
/>
</Label>
<!--
EE only
<Label label="Aggregation function">
<SimpleEditor autoHeight lang="javascript" bind:code={value.aggFunc} />
</Label>
<Label label="Pivot">
<Toggle bind:checked={value.pivot} size="xs" />
</Label>
<Label label="Pivot index">
<input type="number" placeholder="pivot index" bind:value={value.pivotIndex} />
</Label>
<Label label="Pinned">
<select bind:value={value.pinned}>
<option value={null}>None</option>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
<option value="left">Left</option>
<option value="right">Right</option>
</select>
</Label>
<Label label="Filter">
{#snippet header()}
<Tooltip documentationLink="https://www.ag-grid.com/javascript-data-grid/filtering/">
Filtering allows you to limit the rows displayed in your grid to those that match
criteria you specify.
</Tooltip>
{/snippet}
<Toggle
on:pointerdown={(e) => {
e?.stopPropagation()
}}
options={{ right: 'Enable filter' }}
bind:checked={value.filter}
size="xs"
/>
<Label label="Row group">
<Toggle bind:checked={value.rowGroup} size="xs" />
</Label>
<!--
EE only
<Label label="Aggregation function">
<SimpleEditor autoHeight lang="javascript" bind:code={value.aggFunc} />
</Label>
<Label label="Pivot">
<Toggle bind:checked={value.pivot} size="xs" />
</Label>
<Label label="Pivot index">
<input type="number" placeholder="pivot index" bind:value={value.pivotIndex} />
</Label>
<Label label="Pinned">
<select bind:value={value.pinned}>
<option value={null}>None</option>
<option value="left">Left</option>
<option value="right">Right</option>
</select>
</Label>
<Label label="Row group">
<Toggle bind:checked={value.rowGroup} size="xs" />
</Label>
<Label label="Row group index">
<input type="number" placeholder="row group index" bind:value={value.rowGroupIndex} />
</Label>
-->
<Label label="Type">
<select bind:value={value.cellRendererType}>
<option value="text">Text</option>
<option value="link">Link</option>
</select>
<Label label="Row group index">
<input type="number" placeholder="row group index" bind:value={value.rowGroupIndex} />
</Label>
-->
{#if value.cellRendererType === 'link'}
<Alert type="info" title="Label" size="xs">
They are two ways to define a link:
<ul class="list-disc list-inside">
<li>
<strong>String</strong>: The string will be used as the link and the label.
</li>
<li>
<strong>Object</strong>: The object must have a <code>href</code> and a
<code>label</code> property.
</li>
</ul>
</Alert>
<Label label="Type">
<select bind:value={value.cellRendererType}>
<option value="text">Text</option>
<option value="link">Link</option>
</select>
</Label>
{#if value.cellRendererType === 'link'}
<Alert type="info" title="Label" size="xs">
They are two ways to define a link:
<ul class="list-disc list-inside">
<li>
<strong>String</strong>: The string will be used as the link and the label.
</li>
<li>
<strong>Object</strong>: The object must have a <code>href</code> and a
<code>label</code> property.
</li>
</ul>
</Alert>
{/if}
{/if}
</div>
{/if}