Files
windmill/frontend/src/lib/components/triggers/CaptureTable.svelte
T
GuilhemandRuben Fiszel cd440564d2 feat: history for flow inputs (#5117)
* feat (frontend): improve capture UI

* show schema diff

* add accept reject form

* add diff for first node input

* move update schema button

* change modified properties display

* propagate change through nested components

* allow modification of nested object in schema

* reject nested changes

* clean logic

* clean code

* clean

* nit

* fix first strep input

* fix argument preview

* clean

* restore indentation for nested objects

* ignore undefined field in diff computation

* fix run button disabled update

* nit

* fix update JSON

* fix deletion of nested components

* dark mode

* clean

* replace dropdown with sidebar

* only check type for compatibility

* add shadow

* disbale dnd on edit

* auto-scroll within schema

* fix nested not dnd object diff viewer

* fix captures drawer

* open edit tab on add new arg

* change button label when schema is the same

* ajust padding

* fix arg update

* fix oneof display

* propagate change event through nested schema

* handle oneOf

* fix preview arg sync

* handle s3 object

* fix arg sync

* update schema input compatibility

* clean compatible

* accept empty array items

* clean

* increase of schema args gap

* fix nested schema update

* allow number and int compatibility

* reset args when modifying schema

* open fields on add with addPropertyV2

* feat(frontend): use new saved inputs picker

* add object viewer to saved inputs row

* Display alert info on select saved or historic input

* Allow args edit when using saved/historic inputs

* replace input library drawers with panels

* add captures and saved inputs to arg picker

* polish ui

* nit

* clean

* add json arg editor

* fix escape for deselect input

* bind schema form valid

* nit

* handle big payloads

* nit

* nit

* validity check disabled in json view

* improve infinit list request

* fix json schema inferrence

* nit

* nit

* nit

* nit

* fix tipo

* handle large captures

* fix cancel button shift

* use sideTabBar has a trigger

* nit

* fix everything

* fix

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-01-30 16:30:56 +01:00

364 lines
10 KiB
Svelte

<script lang="ts">
import Label from '../Label.svelte'
import { Info, Trash2 } from 'lucide-svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import Button from '../common/button/Button.svelte'
import CustomPopover from '../CustomPopover.svelte'
import { Webhook, Route, Unplug, Mail, Play } from 'lucide-svelte'
import KafkaIcon from '$lib/components/icons/KafkaIcon.svelte'
import { createEventDispatcher, onDestroy } from 'svelte'
import { type TriggerKind } from '../triggers'
import { CaptureService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { type CaptureTriggerKind } from '$lib/gen'
import CaptureButton from '$lib/components/triggers/CaptureButton.svelte'
import InfiniteList from '../InfiniteList.svelte'
import { isObject, sendUserToast } from '$lib/utils'
import SchemaPickerRow from '$lib/components/schema/SchemaPickerRow.svelte'
import { clickOutside } from '$lib/utils'
import type { Capture } from '$lib/gen'
export let path: string
export let hasPreprocessor = false
export let canHavePreprocessor = false
export let isFlow = false
export let captureType: CaptureTriggerKind | undefined = undefined
export let headless = false
export let addButton = false
export let canEdit = false
export let fullHeight = true
let selected: number | undefined = undefined
let testKind: 'preprocessor' | 'main' = 'main'
let isEmpty: boolean = true
let infiniteList: InfiniteList | null = null
let firstClick = true
let capturesLength = 0
$: hasPreprocessor && (testKind = 'preprocessor')
const dispatch = createEventDispatcher<{
openTriggers: {
kind: TriggerKind
config: Record<string, any>
}
applyArgs: {
kind: 'main' | 'preprocessor'
args: Record<string, any> | undefined
}
addPreprocessor: null
updateSchema: {
payloadData: any
redirect: boolean
args?: boolean
}
select: any
testWithArgs: any
}>()
interface CaptureWithPayload extends Capture {
getFullCapture?: () => Promise<any>
payloadData?: any
}
export async function loadCaptures(refresh: boolean = false) {
await infiniteList?.loadData(refresh ? 'refresh' : 'loadMore')
}
function initLoadCaptures(testKind: 'preprocessor' | 'main' = 'main') {
const loadInputsPageFn = async (page: number, perPage: number) => {
const captures = await CaptureService.listCaptures({
workspace: $workspaceStore!,
runnableKind: isFlow ? 'flow' : 'script',
path: path ?? '',
triggerKind: captureType,
page,
perPage
})
let capturesWithPayload: CaptureWithPayload[] = captures.map((capture) => {
let newCapture: CaptureWithPayload = { ...capture }
if (capture.payload === 'WINDMILL_TOO_BIG') {
newCapture = {
...capture,
getFullCapture: () =>
CaptureService.getCapture({
workspace: $workspaceStore!,
id: capture.id
})
}
}
const trigger_extra = isObject(capture.trigger_extra) ? capture.trigger_extra : {}
newCapture.payloadData =
testKind === 'preprocessor'
? capture.payload === 'WINDMILL_TOO_BIG'
? {
payload: capture.payload,
...trigger_extra
}
: typeof capture.payload === 'object'
? {
...capture.payload,
...trigger_extra
}
: trigger_extra
: capture.payload
return newCapture
})
return capturesWithPayload
}
infiniteList?.setLoader(loadInputsPageFn)
const deleteInputFn = async (id: any) => {
await CaptureService.deleteCapture({
workspace: $workspaceStore!,
id
})
}
infiniteList?.setDeleteItemFn(deleteInputFn)
}
async function handleSelect(capture: any) {
if (selected === capture.id) {
deselect()
} else {
const payloadData = await getPayload(capture)
selected = capture.id
dispatch('select', structuredClone(payloadData))
}
}
async function getPayload(capture: any) {
let payloadData = {}
if (capture.getFullCapture) {
const fullCapture = await capture.getFullCapture()
payloadData =
testKind === 'preprocessor'
? {
...(typeof fullCapture.payload === 'object' ? fullCapture.payload : {}),
...(typeof fullCapture.trigger_extra === 'object' ? fullCapture.trigger_extra : {})
}
: fullCapture.payload
} else {
payloadData = structuredClone(capture.payloadData)
}
return payloadData
}
function deselect() {
selected = undefined
dispatch('select', undefined)
}
onDestroy(() => {
deselect()
})
const captureKindToIcon: Record<string, any> = {
webhook: Webhook,
http: Route,
email: Mail,
websocket: Unplug,
kafka: KafkaIcon
}
async function getPropPickerElements(): Promise<HTMLElement[]> {
return Array.from(
document.querySelectorAll('[data-schema-picker], [data-schema-picker] *')
) as HTMLElement[]
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && selected) {
deselect()
event.stopPropagation()
event.preventDefault()
}
}
function handleError(error: { type: string; error: any }) {
if (error.type === 'delete') {
sendUserToast(`Failed to delete capture: ${error.error}`, true)
} else if (error.type === 'load') {
sendUserToast(`Failed to load captures: ${error.error}`, true)
}
}
$: path && infiniteList && initLoadCaptures()
</script>
<svelte:window on:keydown={handleKeydown} />
<Label label="Trigger captures" {headless} class="h-full {headless ? '' : 'flex flex-col gap-1'}">
<svelte:fragment slot="header">
{#if addButton}
<div class="inline-block">
<CaptureButton small={true} on:openTriggers />
</div>
{/if}
</svelte:fragment>
<svelte:fragment slot="action">
{#if canHavePreprocessor && !isEmpty}
<div>
<ToggleButtonGroup
bind:selected={testKind}
class="h-full"
on:selected={(e) => {
initLoadCaptures(e.detail)
}}
>
<ToggleButton value="main" label={isFlow ? 'Flow' : 'Main'} small />
<ToggleButton
value="preprocessor"
label="Preprocessor"
small
tooltip="When the runnable has a preprocessor, it receives additional information about the request"
/>
</ToggleButtonGroup>
</div>
{/if}
</svelte:fragment>
<div
class={fullHeight
? headless
? 'h-full'
: 'min-h-0 grow'
: capturesLength > 7
? 'h-[300px]'
: 'h-fit'}
use:clickOutside={{ capture: false, exclude: getPropPickerElements }}
on:click_outside={() => {
if (firstClick) {
firstClick = false
return
}
deselect()
}}
>
<InfiniteList
bind:this={infiniteList}
selectedItemId={selected}
bind:isEmpty
on:error={(e) => handleError(e.detail)}
on:select={(e) => handleSelect(e.detail)}
bind:length={capturesLength}
>
<svelte:fragment slot="columns">
<colgroup>
<col class="w-8" />
<col class="w-16" />
<col />
</colgroup>
</svelte:fragment>
<svelte:fragment let:item let:hover>
{@const captureIcon = captureKindToIcon[item.trigger_kind]}
<SchemaPickerRow
date={item.created_at}
payloadData={item.payloadData}
selected={selected === item.id}
hovering={hover}
>
<svelte:fragment slot="start">
<div class="center-center">
<svelte:component this={captureIcon} size={12} />
</div>
</svelte:fragment>
<svelte:fragment slot="extra">
{#if canEdit}
<div class="flex flex-row items-center gap-2 px-2">
{#if testKind === 'preprocessor' && !hasPreprocessor}
<CustomPopover noPadding>
<Button
size="xs2"
color="dark"
disabled
endIcon={{
icon: Info
}}
wrapperClasses="h-full"
>
Apply args
</Button>
<svelte:fragment slot="overlay">
<div class="text-sm p-2 flex flex-col gap-1 items-start">
<p> You need to add a preprocessor to use preprocessor captures as args </p>
<Button
size="xs2"
color="dark"
on:click={() => {
dispatch('addPreprocessor')
}}
>
Add preprocessor
</Button>
</div>
</svelte:fragment>
</CustomPopover>
{:else}
<Button
size="xs2"
color={hover || selected === item.id ? 'dark' : 'light'}
dropdownItems={[
{
label: 'Use as input schema',
onClick: async () => {
const payloadData = await getPayload(item)
dispatch('updateSchema', {
payloadData,
redirect: true,
args: true
})
},
disabled: !isFlow || testKind !== 'main'
}
].filter((item) => !item.disabled)}
on:click={async () => {
const payloadData = await getPayload(item)
if (isFlow && testKind === 'main') {
dispatch('testWithArgs', payloadData)
} else {
dispatch('applyArgs', {
kind: testKind,
args: payloadData
})
}
}}
disabled={testKind === 'preprocessor' && !hasPreprocessor}
title={isFlow && testKind === 'main'
? 'Test flow with args'
: testKind === 'preprocessor'
? 'Apply args to preprocessor'
: 'Apply args to inputs'}
startIcon={isFlow && testKind === 'main' ? { icon: Play } : {}}
>
{isFlow && testKind === 'main' ? 'Test' : 'Apply args'}
</Button>
{/if}
<Button
size="xs2"
color="light"
variant="contained"
iconOnly
startIcon={{ icon: Trash2 }}
loading={item.isDeleting}
on:click={() => {
infiniteList?.deleteItem(item.id)
}}
btnClasses="hover:text-white hover:bg-red-500 text-red-500"
/>
</div>
{/if}
</svelte:fragment>
</SchemaPickerRow>
</svelte:fragment>
<svelte:fragment slot="empty">
<div class="text-center text-xs text-tertiary">No captures yet</div>
</svelte:fragment>
</InfiniteList>
</div>
</Label>