Files
windmill/frontend/src/lib/components/apps/components/display/table/AppTable.svelte
T
Faton Ramadani 7a8ffbea46 feat(frontend): Aggrid infinite (#3592)
* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): done

* feat(frontend): done

* feat(frontend): done

* feat(frontend): fix initializing

* feat(frontend): update all refreshButtons

* feat(frontend): fix build
2024-04-24 09:32:28 +02:00

740 lines
23 KiB
Svelte

<script lang="ts">
import { getContext, onDestroy, onMount } from 'svelte'
import type {
AppViewerContext,
BaseAppComponent,
ComponentCustomCSS,
ListContext,
ListInputs,
RichConfigurations
} from '../../../types'
import type { AppInput } from '../../../inputType'
import RunnableWrapper from '../../helpers/RunnableWrapper.svelte'
import { writable } from 'svelte/store'
import {
createSvelteTable,
flexRender,
type HeaderGroup,
type Row,
type Table,
type TableOptions
} from '@tanstack/svelte-table'
import AppButton from '../../buttons/AppButton.svelte'
import { classNames, isObject, sendUserToast } from '$lib/utils'
import DebouncedInput from '../../helpers/DebouncedInput.svelte'
import AppTableFooter from './AppTableFooter.svelte'
import { tableOptions } from './tableOptions'
import Alert from '$lib/components/common/alert/Alert.svelte'
import {
components,
type ButtonComponent,
type CheckboxComponent,
type SelectComponent
} from '../../../editor/component'
import { initCss } from '../../../utils'
import { twMerge } from 'tailwind-merge'
import { connectOutput, initConfig, initOutput } from '$lib/components/apps/editor/appUtils'
import ResolveConfig from '../../helpers/ResolveConfig.svelte'
import AppCheckbox from '../../inputs/AppCheckbox.svelte'
import AppSelect from '../../inputs/AppSelect.svelte'
import RowWrapper from '../../layout/RowWrapper.svelte'
import ResolveStyle from '../../helpers/ResolveStyle.svelte'
import { Popup } from '$lib/components/common'
import ComponentOutputViewer from '$lib/components/apps/editor/contextPanel/ComponentOutputViewer.svelte'
import { EyeIcon, Plug2 } from 'lucide-svelte'
import AppCell from './AppCell.svelte'
import sum from 'hash-sum'
import RefreshButton from '$lib/components/apps/components/helpers/RefreshButton.svelte'
export let id: string
export let componentInput: AppInput | undefined
export let configuration: RichConfigurations
export let actionButtons: (BaseAppComponent &
(ButtonComponent | CheckboxComponent | SelectComponent))[]
export let initializing: boolean | undefined = undefined
export let customCss: ComponentCustomCSS<'tablecomponent'> | undefined = undefined
export let render: boolean
const iterContext = getContext<ListContext>('ListWrapperContext')
const listInputs: ListInputs | undefined = getContext<ListInputs>('ListInputs')
type T = Record<string, any>
let result: Record<string, any>[] | undefined = undefined
const {
app,
worldStore,
componentControl,
selectedComponent,
hoverStore,
mode,
connectingInput
} = getContext<AppViewerContext>('AppViewerContext')
let searchValue = ''
let outputs = initOutput($worldStore, id, {
selectedRowIndex: 0,
selectedRow: undefined,
loading: false,
result: [] as Record<string, any>[],
inputs: {},
search: '',
page: 1
})
let inputs = {}
let loading: boolean = false
$: setSearch(searchValue)
function setSearch(srch: string) {
$table.setPageIndex(0)
outputs?.search?.set(srch)
}
const options = writable<TableOptions<T>>({
...tableOptions,
data: [],
columns: []
})
let table = createSvelteTable(options)
let resolvedConfig = initConfig(
components['tablecomponent'].initialData.configuration,
configuration
)
let selectedRowIndex = -1
function toggleRow(row: Record<string, any>, force: boolean = false) {
let data = { ...row.original }
let index = data['__index']
delete data['__index']
let peak = outputs?.selectedRow.peak()
if (selectedRowIndex !== index || peak == undefined || sum(data) != sum(peak) || force) {
selectedRowIndex = index
outputs?.selectedRow.set(data, force)
outputs?.selectedRowIndex.set(index, force)
if (iterContext && listInputs) {
listInputs.set(id, { selectedRow: data, selectedRowIndex: index })
}
}
}
onDestroy(() => {
listInputs?.remove(id)
})
let mounted = false
onMount(() => {
mounted = true
})
$: resolvedConfig?.selectFirstRowByDefault != false &&
selectedRowIndex === -1 &&
Array.isArray(result) &&
result.length > 0 &&
// We need to wait until the component is mounted so the world is created
mounted &&
outputs &&
toggleRow({ original: { ...result[0], __index: 0 } })
function searchInResult(result: Array<Record<string, any>>, searchValue: string) {
if (searchValue === '') {
return result
}
return result.filter((row) => {
// console.log(Object.values(row).map((value) => value?.toString()))
return Object.values(row).some((value) => value?.toString()?.includes(searchValue))
})
}
function renderCell(x: any, props: any): any {
try {
return flexRender(x, props)
} catch (e) {
return undefined
}
}
let filteredResult: Array<Record<string, any>> = []
function setFilteredResult() {
console.log('setting filtered result')
const wIndex = Array.isArray(result)
? (result as any[]).map((x, i) => ({ ...x, __index: i }))
: [{ error: 'input was not an array' }]
if (resolvedConfig.search === 'By Runnable' || resolvedConfig.search === 'Disabled') {
filteredResult = wIndex ?? []
} else {
filteredResult = searchInResult(wIndex ?? [], searchValue)
}
}
$: (result || resolvedConfig.search || searchValue || resolvedConfig.pagination) &&
setFilteredResult()
$: outputs.page.set($table.getState().pagination.pageIndex)
function rerender() {
if (!Array.isArray(result)) {
return
}
const headers = Array.from(
new Set(result.flatMap((row) => (typeof row == 'object' ? Object.keys(row ?? {}) : [])))
)
$options = {
...tableOptions,
...(resolvedConfig?.pagination?.selected != 'manual'
? {
initialState: {
...resolvedConfig.initialState,
pagination: {
pageSize: resolvedConfig?.pagination?.configuration?.auto?.pageSize ?? 20
}
}
}
: {}),
manualPagination: resolvedConfig?.pagination?.selected == 'manual',
pageCount:
resolvedConfig?.pagination?.selected == 'manual'
? resolvedConfig?.pagination?.configuration.manual.pageCount ?? -1
: undefined,
data: filteredResult,
columns: headers.map((header) => {
return {
accessorKey: header,
cell: (info) => info.getValue()
}
})
}
table = createSvelteTable(options)
if (result) {
//console.log('rerendering table', result[0])
if (resolvedConfig?.selectFirstRowByDefault != false) {
toggleRow({ original: filteredResult[0] }, true)
}
}
if (outputs.page.peak()) {
$table.setPageIndex(outputs?.page.peak())
}
}
$: filteredResult != undefined && rerender()
let css = initCss($app.css?.tablecomponent, customCss)
$componentControl[id] = {
right: (skipActions: boolean | undefined) => {
if (skipActions) {
return false
}
const hasActions = actionButtons.length >= 1
if (hasActions) {
$selectedComponent = [actionButtons[0].id]
return true
}
return false
},
setSelectedIndex: (index: number) => {
if (filteredResult) {
toggleRow({ original: filteredResult[index] }, true)
}
}
}
function getHeaderGroups<T>(table: Table<T>): Array<HeaderGroup<T>> {
try {
return table.getHeaderGroups()
} catch (e) {
sendUserToast("Couldn't render table header groups: " + e, true)
console.error(e)
return []
}
}
function getDisplayNameById(id: string) {
const component = resolvedConfig?.columnDefs?.find((columnDef) => columnDef.field === id)
return component?.headerName
}
function safeVisibleCell<T>(row: Row<T>) {
try {
return row.getVisibleCells()
} catch (e) {
sendUserToast("Couldn't render table header groups: " + e, true)
console.error(e)
return []
}
}
function updateTable(resolvedConfig, searchValue) {
if (resolvedConfig?.columnDefs) {
$table.getAllLeafColumns().map((column) => {
const columnConfig = resolvedConfig.columnDefs.find(
// @ts-ignore
(columnDef) => columnDef.field === column.columnDef.accessorKey
)
if (columnConfig?.hideColumn === column.getIsVisible()) {
column.toggleVisibility()
}
})
$table.setColumnOrder(() =>
resolvedConfig.columnDefs.map((columnDef: { field: any }) => columnDef.field)
)
}
}
$: $table && updateTable(resolvedConfig, searchValue)
function updateCellValue(rowIndex: number, columnIndex: number, newCellValue: string) {
if (result && rowIndex < result.length) {
const updatedRow = { ...result[rowIndex] }
const columnName = $table?.getAllLeafColumns()?.[columnIndex]?.columnDef?.['accessorKey']
if (!columnName) {
sendUserToast(`Column name for index ${columnIndex} is not defined.`, true)
return
}
updatedRow[columnName] = newCellValue
result[rowIndex] = updatedRow
outputs?.result.set([result])
}
}
</script>
{#each Object.keys(components['tablecomponent'].initialData.configuration) as key (key)}
<ResolveConfig
{id}
{key}
bind:resolvedConfig={resolvedConfig[key]}
configuration={configuration[key]}
/>
{/each}
{#each Object.keys(css ?? {}) as key (key)}
<ResolveStyle
{id}
{customCss}
{key}
bind:css={css[key]}
componentStyle={$app.css?.tablecomponent}
/>
{/each}
<RunnableWrapper
{outputs}
{render}
{componentInput}
{id}
bind:initializing
bind:result
bind:loading
>
{#if Array.isArray(result) && result.every(isObject)}
<div class="flex flex-col h-full">
{#if resolvedConfig.search !== 'Disabled'}
<div class="flex flex-row w-full justify-between items-center h-12">
{#if resolvedConfig.search !== 'Disabled'}
<div class="grow max-w-[300px]">
<DebouncedInput
placeholder="Search..."
bind:value={searchValue}
parentClass="h-full "
/>
</div>
{:else}
<div />
{/if}
{#if componentInput?.hideRefreshButton && componentInput['autoRefresh']}
<RefreshButton {id} {loading} />
{/if}
</div>
{:else if componentInput?.hideRefreshButton && componentInput['autoRefresh']}
<div class="absolute right-2 top-2 z-50">
<RefreshButton {id} {loading} />
</div>
{/if}
<div
class={twMerge(
'component-wrapper bg-surface',
'divide-y h-full',
css?.container?.class ?? '',
'wm-table-container',
'flex flex-col'
)}
style={css?.container?.style ?? ''}
>
<div class="overflow-x-auto flex-1 w-full">
<table class="relative w-full border-b">
<thead
class={twMerge(
'bg-surface-secondary text-left',
css?.tableHeader?.class ?? '',
'wm-table-header',
'sticky top-0 z-40'
)}
style={css?.tableHeader?.style ?? ''}
>
{#each getHeaderGroups($table) as headerGroup}
<tr class="divide-x">
{#each headerGroup.headers as header}
{#if header?.column?.columnDef?.header}
{@const context = header?.getContext()}
{#if context}
{@const component = renderCell(header.column.columnDef.header, context)}
{@const displayName = getDisplayNameById(header.id)}
<th class="!p-0">
<div
class="flex flex-row items-center gap-1 px-4 py-4 text-xs text-primary font-medium border-b"
>
{#if displayName}
{displayName}
{:else if !header.isPlaceholder && component}
<svelte:component this={component} />
{/if}
{#if header.column.getIsVisible()}
<button
class="w-6 flex justify-center items-center"
on:click={() => {
header.column.toggleVisibility()
}}
>
<EyeIcon
size="14"
class="hover:text-gray-600 text-gray-400 rounded-full "
/>
</button>
{/if}
</div>
</th>
{/if}
{/if}
{/each}
{#if actionButtons.length > 0}
<th class="!p-0">
<div
class="flex flex-row items-center px-4 py-4 text-xs text-primary font-medium border-b"
>
Actions
</div>
</th>
{/if}
</tr>
{/each}
</thead>
<tbody
class={twMerge('divide-y bg-surface', css?.tableBody?.class ?? '', 'wm-table-body')}
style={css?.tableBody?.style ?? ''}
>
{#each $table.getRowModel().rows as row, index (row.id)}
{@const isLastRow = index === filteredResult.length - 1}
{@const rowIndex = row.original['__index']}
<tr
class={twMerge(
isLastRow ? '!border-b-0' : '',
'divide-x w-full',
index % 2 === 0 ? 'bg-gray-50/50' : '',
selectedRowIndex === rowIndex
? 'bg-blue-100 hover:bg-blue-200 dark:bg-surface-selected dark:hover:bg-surface-hover divide-blue-200 hover:divide-blue-300 dark:divide-gray-600 dark:hover:divide-gray-700 wm-table-row-selected'
: 'hover:bg-blue-50 dark:hover:bg-surface-hover wm-table-row'
)}
>
{#each safeVisibleCell(row) as cell, index (index)}
{#if cell?.column?.columnDef?.cell}
{@const context = cell?.getContext()}
{#if context}
<AppCell
on:keydown={() => toggleRow(row)}
on:click={() => toggleRow(row)}
type={resolvedConfig.columnDefs?.find(
// TS types are wrong here
// @ts-ignore
(c) => c.field === cell.column.columnDef.accessorKey
)?.type ?? 'text'}
value={cell.getValue()}
width={cell.column.getSize()}
on:update={(event) => {
updateCellValue(rowIndex, index, event.detail.value)
}}
/>
{/if}
{/if}
{/each}
{#if actionButtons.length > 0}
<td
class="p-2"
on:keypress={() => toggleRow(row)}
on:click={() => toggleRow(row)}
style="width: {(actionButtons ?? []).length * 130}px"
>
<div class="center-center h-full w-full flex-wrap gap-2">
{#each actionButtons as actionButton, actionIndex (actionButton?.id)}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<RowWrapper
value={row.original}
index={rowIndex}
onSet={(id, value) => {
if (!inputs[id]) {
inputs[id] = { [rowIndex]: value }
} else {
inputs[id] = { ...inputs[id], [rowIndex]: value }
}
outputs?.inputs.set(inputs, true)
}}
onRemove={(id) => {
if (inputs?.[id] == undefined) {
return
}
delete inputs[id][rowIndex]
inputs[id] = { ...inputs[id] }
if (Object.keys(inputs?.[id] ?? {}).length == 0) {
delete inputs[id]
inputs = { ...inputs }
}
outputs?.inputs.set(inputs, true)
}}
>
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<div
on:mouseover|stopPropagation={() => {
if (actionButton.id !== $hoverStore) {
$hoverStore = actionButton.id
}
}}
on:mouseout|stopPropagation={() => {
if ($hoverStore !== undefined) {
$hoverStore = undefined
}
}}
class={classNames(
($selectedComponent?.includes(actionButton.id) ||
$hoverStore === actionButton.id) &&
$mode !== 'preview'
? 'outline outline-indigo-500 outline-1 outline-offset-1 relative'
: 'relative'
)}
>
{#if $mode !== 'preview'}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span
title={`Id: ${actionButton.id}`}
class={classNames(
'px-2 text-2xs font-bold w-fit absolute shadow -top-2 -left-2 border z-50 rounded-sm',
'bg-indigo-500/90 border-indigo-600 text-white',
$selectedComponent?.includes(actionButton.id) ||
$hoverStore === actionButton.id
? 'opacity-100'
: 'opacity-0'
)}
on:click|stopPropagation={() => {
$selectedComponent = [actionButton.id]
}}
>
{actionButton.id}
</span>
{#if $connectingInput.opened}
<div class="absolute z-50 left-8 -top-[10px]">
<Popup
floatingConfig={{
strategy: 'absolute',
placement: 'bottom-start'
}}
>
<svelte:fragment slot="button">
<button
class="bg-red-500/70 border border-red-600 px-1 py-0.5"
title="Outputs"
aria-label="Open output"><Plug2 size={12} /></button
>
</svelte:fragment>
<ComponentOutputViewer
suffix="table"
on:select={({ detail }) =>
connectOutput(
connectingInput,
'buttoncomponent',
actionButton.id,
detail
)}
componentId={actionButton.id}
/>
</Popup>
</div>
{/if}
{/if}
{#if rowIndex == 0}
{@const controls = {
left: () => {
if (actionIndex === 0) {
$selectedComponent = [id]
return true
} else if (actionIndex > 0) {
$selectedComponent = [actionButtons[actionIndex - 1].id]
return true
}
return false
},
right: () => {
if (actionIndex === actionButtons.length - 1) {
return id
} else if (actionIndex < actionButtons.length - 1) {
$selectedComponent = [actionButtons[actionIndex + 1].id]
return true
}
return false
}
}}
{#if actionButton.type == 'buttoncomponent'}
<AppButton
noInitialize
extraKey={'idx' + rowIndex}
{render}
noWFull
preclickAction={async () => {
toggleRow(row)
}}
id={actionButton.id}
customCss={actionButton.customCss}
configuration={actionButton.configuration}
recomputeIds={actionButton.recomputeIds}
extraQueryParams={{ row: row.original }}
componentInput={actionButton.componentInput}
{controls}
/>
{:else if actionButton.type == 'checkboxcomponent'}
<AppCheckbox
noInitialize
extraKey={'idx' + rowIndex}
{render}
id={actionButton.id}
customCss={actionButton.customCss}
configuration={actionButton.configuration}
recomputeIds={actionButton.recomputeIds}
onToggle={actionButton.onToggle}
preclickAction={async () => {
toggleRow(row)
}}
{controls}
/>
{:else if actionButton.type == 'selectcomponent'}
<div class="w-40">
<AppSelect
noDefault
noInitialize
extraKey={'idx' + rowIndex}
{render}
id={actionButton.id}
customCss={actionButton.customCss}
configuration={actionButton.configuration}
recomputeIds={actionButton.recomputeIds}
onSelect={actionButton.onSelect}
preclickAction={async () => {
toggleRow(row)
}}
{controls}
/>
</div>
{/if}
{:else if actionButton.type == 'buttoncomponent'}
<AppButton
noInitialize
extraKey={'idx' + rowIndex}
{render}
noWFull
id={actionButton.id}
customCss={actionButton.customCss}
configuration={actionButton.configuration}
recomputeIds={actionButton.recomputeIds}
preclickAction={async () => {
toggleRow(row)
}}
extraQueryParams={{ row: row.original }}
componentInput={actionButton.componentInput}
/>
{:else if actionButton.type == 'checkboxcomponent'}
<AppCheckbox
noInitialize
extraKey={'idx' + rowIndex}
{render}
id={actionButton.id}
customCss={actionButton.customCss}
configuration={actionButton.configuration}
recomputeIds={actionButton.recomputeIds}
onToggle={actionButton.onToggle}
preclickAction={async () => {
toggleRow(row)
}}
/>
{:else if actionButton.type == 'selectcomponent'}
<div class="w-40">
<AppSelect
noDefault
noInitialize
extraKey={'idx' + rowIndex}
{render}
id={actionButton.id}
customCss={actionButton.customCss}
configuration={actionButton.configuration}
recomputeIds={actionButton.recomputeIds}
onSelect={actionButton.onSelect}
preclickAction={async () => {
toggleRow(row)
}}
/>
</div>
{/if}
</div>
</RowWrapper>
{/each}
</div>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
<AppTableFooter
download={resolvedConfig?.downloadButton}
pageSize={resolvedConfig?.pagination?.configuration?.auto?.pageSize ?? 20}
manualPagination={resolvedConfig?.pagination?.selected == 'manual'}
result={filteredResult}
{table}
class={twMerge(css?.tableFooter?.class, 'wm-table-footer')}
style={css?.tableFooter?.style}
{loading}
/>
</div>
</div>
{:else if result != undefined}
<div class="flex flex-col h-full w-full overflow-auto">
<Alert title="Parsing issues" type="error" size="xs" class="h-full w-full ">
The result should be an array of objects. Received:
<pre class="w-full bg-surface p-2 rounded-md whitespace-pre-wrap mt-2"
>{JSON.stringify(result, null, 4)}
</pre>
</Alert>
</div>
{/if}
</RunnableWrapper>