feat (frontend): improve capture UI (#5051)

* 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

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Guilhem
2025-01-22 12:51:14 +01:00
committed by GitHub
parent 9d03652049
commit 0dd16ab99d
18 changed files with 1447 additions and 879 deletions
File diff suppressed because it is too large Load Diff
@@ -12,7 +12,6 @@
import PropertyEditor from './schema/PropertyEditor.svelte'
import SimpleEditor from './SimpleEditor.svelte'
import { createEventDispatcher } from 'svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import Label from './Label.svelte'
@@ -23,6 +22,7 @@
import SchemaFormDnd from './schema/SchemaFormDND.svelte'
import { deepEqual } from 'fast-equals'
import { tweened } from 'svelte/motion'
import type { SchemaDiff } from '$lib/components/schema/schemaUtils'
export let schema: Schema | any
export let schemaSkippedValues: string[] = []
@@ -49,6 +49,9 @@
export let previewSchema: Record<string, any> | undefined = undefined
export let editPanelInitialSize: number | undefined = undefined
export let editPanelSize = 0
export let diff: Record<string, SchemaDiff> = {}
export let disableDnd: boolean = false
export let shouldDispatchChanges: boolean = false
const dispatch = createEventDispatcher()
@@ -232,6 +235,11 @@
let pannelButtonWidth: number = 0
export let pannelExtraButtonWidth: number = 0
export function updateJson() {
schemaString = JSON.stringify(schema, null, '\t')
editor?.setCode(schemaString)
}
</script>
<div class="w-full h-full">
@@ -247,37 +255,60 @@
<Splitpanes class="splitter-hidden w-full">
{#if !noPreview}
<Pane bind:size={inputPanelSize} minSize={20}>
<div class="flex flex-col pr-2 gap-2">
<div
class="h-full flex flex-col gap-2 {$$slots.openEditTab && editPanelSize > 0
? 'pr-[38px]'
: 'pr-2'}"
>
{#if $$slots.addProperty}
<div class="w-full justify-left pr-2">
<div style={`width: calc(100% - ${pannelButtonWidth - pannelExtraButtonWidth}px);`}>
<div class="w-full justify-left pr-2 grow-0">
<div
style={editPanelSize > 0
? `width: 100%;`
: `width: calc(100% - ${pannelButtonWidth - pannelExtraButtonWidth}px);`}
>
<slot name="addProperty" />
</div>
</div>
{/if}
<SchemaFormDnd
schema={previewSchema ? previewSchema : schema}
{dndType}
bind:args
on:click={(e) => {
opened = e.detail
}}
on:reorder={(e) => {
schema.order = e.detail
schema = schema
dispatch('change', schema)
}}
on:change={() => {
schema = schema
dispatch('change', schema)
}}
{lightweightMode}
prettifyHeader={isAppInput}
disabled={!!previewSchema}
/>
<div
class="min-h-0 overflow-y-auto grow rounded-md {$$slots.runButton
? 'flex flex-col gap-2'
: ''}"
>
<SchemaFormDnd
nestedClasses={'flex flex-col gap-1'}
schema={previewSchema ? previewSchema : schema}
{dndType}
{disableDnd}
bind:args
on:click={(e) => {
opened = e.detail
}}
on:reorder={(e) => {
schema.order = e.detail
schema = schema
dispatch('change', schema)
}}
on:change={() => {
schema = schema
dispatch('change', schema)
}}
{lightweightMode}
prettifyHeader={isAppInput}
disabled={!!previewSchema}
{diff}
on:acceptChange
on:rejectChange
on:nestedChange={() => {
dispatch('change', schema)
}}
{shouldDispatchChanges}
/>
<slot name="runButton" />
<slot name="runButton" />
</div>
</div>
</Pane>
{/if}
@@ -425,7 +456,6 @@
{isAppInput}
on:change={() => {
schema = schema
// console.log('schema', schema)
dispatch('change', schema)
}}
>
@@ -440,8 +470,6 @@
const isS3 = e.detail == 'S3'
const isOneOf = e.detail == 'oneOf'
selected = e.detail
const emptyProperty = {
contentEncoding: undefined,
enum_: undefined,
@@ -508,8 +536,12 @@
type: e.detail
}
}
schema = schema
dispatch('change', schema)
// No better solution than this, needs future rework
setTimeout(() => {
schema = schema
dispatch('change', schema)
}, 100)
dispatch('schemaChange')
}}
>
{#each [['String', 'string'], ['Number', 'number'], ['Integer', 'integer'], ['Object', 'object'], ['OneOf', 'oneOf'], ['Array', 'array'], ['Boolean', 'boolean'], ['S3 Object', 'S3']] as x}
@@ -558,6 +590,7 @@
on:schemaChange={(e) => {
schema = schema
dispatch('change', schema)
dispatch('schemaChange')
}}
/>
{/if}
@@ -27,7 +27,7 @@
error = e
}
}
loadSchema()
$: $flowStore && $flowStateStore && loadSchema()
function handleClick() {
selected = !selected
@@ -51,6 +51,8 @@
if (event.key === 'Escape' && selected) {
selected = false
dispatch('select', undefined)
event.stopPropagation()
event.preventDefault()
}
}
@@ -41,6 +41,7 @@
let jobProgressReset: () => void
export function test() {
renderCount++
runPreview($previewArgs, undefined)
}
@@ -159,53 +160,53 @@
on:close={captureLibraryDrawer?.toggleDrawer}
>
<div class="h-full flex flex-col gap-2">
<CapturesInputs
flowPath={$pathStore}
headless={false}
addButton={true}
on:select={(e) => {
selectedCapture = e.detail
}}
on:openTriggers={(e) => {
dispatch('openTriggers', e.detail)
captureLibraryDrawer?.closeDrawer()
}}
/>
<div class="min-h-0 grow h-full">
<CapturesInputs
flowPath={$pathStore}
headless={false}
addButton={true}
on:select={(e) => {
selectedCapture = e.detail
}}
on:openTriggers={(e) => {
dispatch('openTriggers', e.detail)
captureLibraryDrawer?.closeDrawer()
}}
/>
</div>
<div class="h-full overflow-hidden min-h-0 flex flex-col justify-between">
<div class="w-full flex flex-col min-h-0 gap-2 px-2 py-2 grow">
<div class="w-full flex flex-col">
<Button
color="blue"
btnClasses="w-full"
size="sm"
spacingSize="xl"
on:click={async () => {
$previewArgs = JSON.parse(JSON.stringify(selectedCapture))
captureLibraryDrawer?.closeDrawer()
renderCount++
}}
disabled={!selectedCapture}
<div class="w-full flex flex-col gap-2 px-2 py-2 h-[50%]">
<div class="w-full flex flex-col">
<Button
color="blue"
btnClasses="w-full"
size="sm"
spacingSize="xl"
on:click={async () => {
$previewArgs = JSON.parse(JSON.stringify(selectedCapture))
captureLibraryDrawer?.closeDrawer()
renderCount++
}}
disabled={!selectedCapture}
>
<ArrowLeftIcon class="w-4 h-4 mr-2" />
Use input
</Button>
</div>
<div class="w-full min-h-0 grow overflow-auto">
{#if typeof selectedCapture == 'string' && selectedCapture == 'WINDMILL_TOO_BIG'}
<div class="text-secondary mt-2">
Payload too big to preview but can still be loaded</div
>
<ArrowLeftIcon class="w-4 h-4 mr-2" />
Use input
</Button>
</div>
<div class="w-full min-h-0 grow overflow-auto">
{#if typeof selectedCapture == 'string' && selectedCapture == 'WINDMILL_TOO_BIG'}
<div class="text-secondary mt-2">
Payload too big to preview but can still be loaded</div
>
{:else if Object.keys(selectedCapture || {}).length > 0}
<div class=" overflow-auto h-full p-2">
<ObjectViewer json={selectedCapture} />
</div>
{:else}
<div class="text-center text-tertiary">
Select an Input to preview scripts arguments
</div>
{/if}
</div>
{:else if Object.keys(selectedCapture || {}).length > 0}
<div class=" overflow-auto h-full p-2">
<ObjectViewer json={selectedCapture} />
</div>
{:else}
<div class="text-center text-tertiary">
Select an Input to preview scripts arguments
</div>
{/if}
</div>
</div>
</div>
@@ -102,6 +102,8 @@
if (event.key === 'Escape' && selected) {
selected = undefined
dispatch('select', undefined)
event.stopPropagation()
event.preventDefault()
}
}
@@ -119,16 +119,22 @@
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && isEditing) {
setEditing(null)
event.stopPropagation()
event.preventDefault()
} else if (event.key === 'Escape' && selectedInput) {
selectedInput = null
selectedArgs = undefined
dispatch('select', undefined)
event.stopPropagation()
event.preventDefault()
}
if (event.key === 'Enter' && isEditing) {
updateInput(isEditing).then(() => {
setEditing(null)
})
event.stopPropagation()
event.preventDefault()
}
}
+71 -4
View File
@@ -15,6 +15,7 @@
import LightweightArgInput from './LightweightArgInput.svelte'
import { deepEqual } from 'fast-equals'
import { dragHandleZone, type Options as DndOptions } from '@windmill-labs/svelte-dnd-action'
import type { SchemaDiff } from '$lib/components/schema/schemaUtils'
export let schema: Schema | any
export let schemaSkippedValues: string[] = []
@@ -46,6 +47,10 @@
| { type: 'hash'; hash: string }
| undefined = undefined
export let lightHeader = false
export let diff: Record<string, SchemaDiff> = {}
export let nestedParent: { label: string; nestedParent: any | undefined } | undefined = undefined
export let shouldDispatchChanges = false
export let nestedClasses = ''
const dispatch = createEventDispatcher()
@@ -166,7 +171,9 @@
{/if}
<div
class="w-full {$$props.class} {flexWrap ? 'flex flex-row flex-wrap gap-x-6 ' : ''}"
class="w-full {$$props.class} {flexWrap
? 'flex flex-row flex-wrap gap-x-6 '
: ''} {nestedClasses}"
use:dragHandleZone={dndConfig ?? { items: [], dragDisabled: true }}
on:finalize
on:consider
@@ -174,12 +181,61 @@
{#if keys.length > 0}
{#each fields as item, i (item.id)}
{@const argName = item.value}
<div>
<div
class={typeof diff[argName] === 'object' && diff[argName].diff !== 'same'
? 'bg-red-300 dark:bg-red-800 rounded-md'
: ''}
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
{#if !schemaSkippedValues.includes(argName) && keys.includes(argName)}
{#if typeof diff[argName] === 'object' && diff[argName].oldSchema}
{@const formerProperty = diff[argName].oldSchema}
<ArgInput
{disablePortal}
{resourceTypes}
{prettifyHeader}
autofocus={i == 0 && autofocus ? true : null}
label={argName}
description={formerProperty?.description}
value={args[argName]}
type={formerProperty?.type}
oneOf={formerProperty?.oneOf}
required={formerProperty?.required}
pattern={formerProperty?.pattern}
valid={inputCheck[argName]}
defaultValue={structuredClone(formerProperty?.default)}
enum_={formerProperty?.enum}
format={formerProperty?.format}
contentEncoding={formerProperty?.contentEncoding}
customErrorMessage={formerProperty?.customErrorMessage}
properties={formerProperty?.properties}
order={formerProperty?.order}
nestedRequired={formerProperty?.required}
itemsType={formerProperty?.items}
disabled={disabledArgs.includes(argName) || disabled || formerProperty?.disabled}
{compact}
{variableEditor}
{itemPicker}
{pickForField}
password={linkedSecret == argName}
extra={formerProperty}
{showSchemaExplorer}
simpleTooltip={schemaFieldTooltip[argName]}
{onlyMaskPassword}
nullable={formerProperty?.nullable}
title={formerProperty?.title}
placeholder={formerProperty?.placeholder}
orderEditable={dndConfig != undefined}
otherArgs={args}
{helperScript}
{lightHeader}
hideNested={typeof diff[argName].diff === 'object'}
diffStatus={undefined}
/>
{/if}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="flex flex-row items-center bg-surface"
class="flex flex-row items-center"
on:click={() => {
dispatch('click', argName)
}}
@@ -244,7 +300,14 @@
</LightweightArgInput>
{:else}
<ArgInput
on:change={() => dispatch('change')}
on:change={() => {
dispatch('change')
}}
on:nestedChange={() => {
dispatch('nestedChange')
}}
on:acceptChange={(e) => dispatch('acceptChange', e.detail)}
on:rejectChange={(e) => dispatch('rejectChange', e.detail)}
{disablePortal}
{resourceTypes}
{prettifyHeader}
@@ -285,6 +348,10 @@
otherArgs={args}
{helperScript}
{lightHeader}
diffStatus={diff[argName] ?? undefined}
{nestedParent}
{shouldDispatchChanges}
{nestedClasses}
>
<svelte:fragment slot="actions">
<slot name="actions" />
@@ -116,7 +116,10 @@
let placeholderVisible = false
function updatePlaceholderVisibility(value: string) {
if (!value) return
if (!value) {
placeholderVisible = true
return
}
placeholderVisible = value.trim() === ''
}
@@ -1,7 +1,7 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { ButtonType } from '$lib/components/common/button/model'
import { getContext } from 'svelte'
import { getContext, tick } from 'svelte'
import FlowCard from '../common/FlowCard.svelte'
import type { FlowEditorContext } from '../types'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
@@ -18,21 +18,30 @@
CornerDownLeft,
Pen,
ChevronRight,
ChevronDown,
Plus,
History,
Braces,
Code,
Save,
X
X,
Check
} from 'lucide-svelte'
import CaptureIcon from '$lib/components/triggers/CaptureIcon.svelte'
import FlowPreviewContent from '$lib/components/FlowPreviewContent.svelte'
import FlowInputEditor from './FlowInputEditor.svelte'
import CapturesInputs from '$lib/components/CapturesInputs.svelte'
import { twMerge } from 'tailwind-merge'
import ButtonDropDown from '$lib/components/meltComponents/ButtonDropDown.svelte'
import CaptureButton from '$lib/components/triggers/CaptureButton.svelte'
import {
type SchemaDiff,
getFullPath,
setNestedProperty,
getNestedProperty,
schemaFromDiff,
computeDiff,
applyDiff
} from '$lib/components/schema/schemaUtils'
import SideBarTab from '$lib/components/meltComponents/SideBarTab.svelte'
export let noEditor: boolean
export let disabled: boolean
@@ -45,14 +54,18 @@
let previewSchema: Record<string, any> | undefined = undefined
let payloadData: Record<string, any> | undefined = undefined
let previewArguments: Record<string, any> | undefined = $previewArgs
let editOptionsOpen = false
let dropdownItems: Array<{
label: string
onClick: () => void
disabled?: boolean
}> = []
let diff: Record<string, SchemaDiff> = {}
let editPanelSize = $flowInputEditorState?.editPanelSize ?? 0
let selectedSchema: Record<string, any> | undefined = undefined
let runDisabled: boolean = false
let editableSchemaForm: EditableSchemaForm | undefined = undefined
let savedPreviewArgs: Record<string, any> | undefined = undefined
function updateEditPanelSize(size: number | undefined) {
if (!$flowInputEditorState) return
if (!size || size === 0) {
@@ -73,7 +86,8 @@
disabled:
!$flowInputEditorState?.selectedTab ||
$flowInputEditorState?.selectedTab === 'inputEditor',
icon: Pen
icon: Pen,
selected: $flowInputEditorState?.selectedTab === 'inputEditor'
},
{
label: 'Trigger captures',
@@ -81,7 +95,8 @@
handleEditSchema('captures')
},
disabled: $flowInputEditorState?.selectedTab === 'captures',
icon: CaptureIcon
icon: CaptureIcon,
selected: $flowInputEditorState?.selectedTab === 'captures'
},
{
label: 'History',
@@ -89,7 +104,8 @@
handleEditSchema('history')
},
disabled: $flowInputEditorState?.selectedTab === 'history',
icon: History
icon: History,
selected: $flowInputEditorState?.selectedTab === 'history'
},
{
label: 'Json payload',
@@ -97,14 +113,16 @@
handleEditSchema('json')
},
disabled: $flowInputEditorState?.selectedTab === 'json',
icon: Braces
icon: Braces,
selected: $flowInputEditorState?.selectedTab === 'json'
},
{
label: "First step's inputs",
onClick: () => {
handleEditSchema('firstStepInputs')
},
icon: Code
icon: Code,
selected: $flowInputEditorState?.selectedTab === 'firstStepInputs'
},
{
label: 'Saved inputs',
@@ -112,9 +130,10 @@
handleEditSchema('savedInputs')
},
disabled: $flowInputEditorState?.selectedTab === 'savedInputs',
icon: Save
icon: Save,
selected: $flowInputEditorState?.selectedTab === 'savedInputs'
}
].filter((item) => !item.disabled)
]
}
function handleEditSchema(editTab?: any) {
@@ -130,7 +149,8 @@
}
}
function schemaFromPayload(payload: any) {
function schemaFromPayload(payloadData: any) {
const payload = structuredClone(payloadData)
const parsed = JSON.parse(JSON.stringify(payload))
if (!parsed) {
@@ -157,43 +177,59 @@
function handleKeydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
runPreview()
} else if (event.key === 'Enter' && previewSchema && !preventEnter) {
applySchemaAndArgs()
connectFirstNode()
event.stopPropagation()
event.preventDefault()
}
}
function runPreview() {
if (previewArguments) {
$previewArgs = previewArguments
$previewArgs = structuredClone(previewArguments)
}
previewOpen = true
flowPreviewContent?.test()
}
function updatePreviewSchemaAndArgs(payload: any) {
payloadData = payload
if (!payload) {
payloadData = undefined
selectedSchema = undefined
updatePreviewArguments(undefined)
updatePreviewSchema(undefined)
return
}
payloadData = structuredClone(payload)
selectedSchema = schemaFromPayload(payloadData)
updatePreviewSchema(selectedSchema)
updatePreviewArguments(payloadData)
}
async function updatePreviewSchema(newSchema: Record<string, any> | undefined) {
if (!newSchema) {
previewSchema = undefined
if (runDisabled) {
await tick()
runDisabled = false
}
diff = {}
return
}
previewSchema = schemaFromPayload(payload)
updatePreviewArguments(payload)
diff = {}
const diffSchema = computeDiff(newSchema, $flowStore.schema)
diff = diffSchema
previewSchema = schemaFromDiff(diffSchema, $flowStore.schema)
runDisabled = true
}
function applySchemaAndArgs() {
if (!previewSchema) {
return
}
$flowStore.schema = previewSchema
async function applySchemaAndArgs() {
$flowStore.schema = applyDiff($flowStore.schema, diff)
if (previewArguments) {
$previewArgs = previewArguments
savedPreviewArgs = structuredClone(previewArguments)
}
if ($flowInputEditorState) {
$flowInputEditorState.selectedTab = undefined
}
}
function applySchema() {
$flowStore.schema = previewSchema
updatePreviewSchemaAndArgs(undefined)
if ($flowInputEditorState) {
$flowInputEditorState.selectedTab = undefined
}
@@ -201,41 +237,28 @@
function updatePreviewArguments(payloadData: Record<string, any> | undefined) {
if (!payloadData) {
previewArguments = $previewArgs
previewArguments = savedPreviewArgs
return
}
previewArguments = payloadData
savedPreviewArgs = structuredClone(previewArguments)
previewArguments = structuredClone(payloadData)
}
let jsonValid = false
function updatePayloadFromJson(jsonInput: string) {
if (jsonInput === undefined || jsonInput === null || jsonInput.trim() === '') {
updatePreviewSchemaAndArgs(undefined)
jsonValid = false
return
}
try {
const parsed = JSON.parse(jsonInput)
updatePreviewSchemaAndArgs(parsed)
jsonValid = true
} catch (error) {
updatePreviewSchemaAndArgs(undefined)
jsonValid = false
}
}
let tabButtonWidth = 0
const TAB_TITLES: Record<string, string> = {
inputEditor: 'Input editor',
captures: 'Captures',
history: 'History',
savedInputs: 'Saved inputs',
json: 'JSON',
firstStepInputs: 'First step',
undefined: ''
}
let connectFirstNode: () => void = () => {}
let init = false
@@ -249,6 +272,54 @@
$: $flowInputEditorState && ((dropdownItems = getDropdownItems()), initPayloadData())
let preventEnter = false
async function acceptChange(arg: { label: string; nestedParent: any | undefined }) {
handleChange(arg, $flowStore.schema, diff, (newSchema) => {
$flowStore.schema = newSchema
})
}
async function rejectChange(arg: { label: string; nestedParent: any | undefined }) {
const revertDiff = computeDiff($flowStore.schema, selectedSchema)
handleChange(arg, selectedSchema, revertDiff, (newSchema) => {
selectedSchema = newSchema
})
}
function handleChange(
arg: { label: string; nestedParent: any | undefined },
currentSchema: Record<string, any> | undefined,
diffSchema: Record<string, SchemaDiff> | undefined,
updateCurrentSchema: (newSchema: Record<string, any> | undefined) => void
) {
if (!diff || !currentSchema) {
return
}
const path = getFullPath(arg)
const parentPath = path.slice(0, -1)
const diffStatus = getNestedProperty({ diff: diffSchema }, path, 'diff')
const schema = getNestedProperty(currentSchema, parentPath, 'properties')
const localDiff = {
[arg.label]: diffStatus
}
const schemaUpdated = applyDiff(schema, localDiff)
if (parentPath.length > 0) {
setNestedProperty(currentSchema, parentPath, schemaUpdated)
} else {
updateCurrentSchema(schemaUpdated)
}
diff = computeDiff(selectedSchema, $flowStore.schema)
previewSchema = schemaFromDiff(diff, $flowStore.schema)
}
function resetArgs() {
if (!previewSchema) {
previewArguments = undefined
savedPreviewArgs = undefined
}
}
</script>
<!-- Add svelte:window to listen for keyboard events -->
@@ -269,6 +340,7 @@
{#if !disabled}
<div class="py-2 px-4 h-full">
<EditableSchemaForm
bind:this={editableSchemaForm}
bind:schema={$flowStore.schema}
isFlowInput
on:edit={(e) => {
@@ -284,78 +356,84 @@
bind:editPanelSize
editPanelInitialSize={$flowInputEditorState?.editPanelSize}
pannelExtraButtonWidth={$flowInputEditorState?.editPanelSize ? tabButtonWidth : 0}
{diff}
disableDnd={!!previewSchema}
on:rejectChange={(e) => {
rejectChange(e.detail).then(() => {
updatePreviewSchema(selectedSchema)
})
}}
on:acceptChange={(e) => {
acceptChange(e.detail).then(() => {
updatePreviewSchema(selectedSchema)
})
}}
shouldDispatchChanges={true}
on:change={() => {
previewArguments = previewArguments
if (!previewSchema) {
savedPreviewArgs = structuredClone(previewArguments)
}
}}
on:schemaChange={() => {
resetArgs()
}}
>
<svelte:fragment slot="openEditTab">
<div
class={twMerge(
'flex flex-row divide-x rounded-md bg-surface overflow-hidden',
!!$flowInputEditorState?.selectedTab ? 'rounded-r-none' : '',
ButtonType.ColorVariants.blue.divider
)}
>
<button
on:click={() => {
handleEditSchema()
}}
title={!!$flowInputEditorState?.selectedTab
? 'Close input editor'
: 'Open input editor'}
class={ButtonType.ColorVariants.blue.contained}
>
<div class="p-2 center-center">
<svelte:component
this={!!$flowInputEditorState?.selectedTab ? ChevronRight : Pen}
size={14}
/>
</div>
</button>
<ButtonDropDown
{dropdownItems}
closeOnClick={true}
bind:open={editOptionsOpen}
placement="bottom-end"
>
<div
class={twMerge(
'p-2 center-center hover:bg-surface-hover',
ButtonType.ColorVariants.blue.contained,
'flex flex-row items-center rounded-br-md',
'transition-all duration-150 ease-in-out overflow-hidden whitespace-nowrap',
!!$flowInputEditorState?.selectedTab ? 'w-[122px] px-3' : 'w-[30px]'
)}
bind:clientWidth={tabButtonWidth}
>
<div class="flex flex-row items-center gap-1 justify-between w-full">
{#if !!$flowInputEditorState?.selectedTab}
<h2 class="text-xs">{TAB_TITLES[$flowInputEditorState?.selectedTab]}</h2>
{/if}
<ChevronDown size={14} />
</div>
</div>
</ButtonDropDown>
<div class={twMerge('flex flex-row divide-x', ButtonType.ColorVariants.blue.divider)}>
<SideBarTab {dropdownItems} fullMenu={!!$flowInputEditorState?.selectedTab}>
<svelte:fragment slot="close button">
<button
on:click={() => {
handleEditSchema()
}}
title={!!$flowInputEditorState?.selectedTab
? 'Close input editor'
: 'Open input editor'}
class={twMerge(
ButtonType.ColorVariants.blue.contained,
!!$flowInputEditorState?.selectedTab
? 'rounded-tl-md border-l border-t'
: 'rounded-md border'
)}
>
<div class="p-2 center-center">
<svelte:component
this={!!$flowInputEditorState?.selectedTab ? ChevronRight : Pen}
size={14}
/>
</div>
</button>
</svelte:fragment>
</SideBarTab>
</div>
</svelte:fragment>
<svelte:fragment slot="addProperty">
{#if !!previewSchema}
<div
class={twMerge(
'bg-blue-50 border-blue-200 border dark:bg-blue-900/40 dark:border-blue-700/40 text-xs p-2 w-full flex flex-row gap-2 items-center justify-left rounded-md',
'text-blue-700 dark:text-blue-100',
'relative'
)}
>
<span> Preview only, update schema to save.</span>
<div class="flex flex-row items-center gap-2 absolute right-2">
<Button
variant="contained"
color="light"
size="xs2"
startIcon={{ icon: X }}
shortCut={{ key: 'esc', withoutModifier: true }}
nonCaptureEvent
/>
</div>
<div class="flex flex-row items-center gap-2 right-2 justify-end">
<Button
size="xs"
color="green"
disabled={!previewSchema}
shortCut={{ Icon: CornerDownLeft, hide: false, withoutModifier: true }}
startIcon={{ icon: Check }}
on:click={() => {
applySchemaAndArgs()
connectFirstNode()
}}
>
{Object.values(diff).every((el) => el.diff === 'same')
? 'Apply args'
: 'Update schema'}
</Button>
<Button
variant="border"
color="light"
size="xs"
startIcon={{ icon: X }}
shortCut={{ key: 'esc', withoutModifier: true }}
nonCaptureEvent
/>
</div>
{:else}
<AddPropertyV2
@@ -363,6 +441,14 @@
bind:this={addProperty}
on:change={() => {
$flowStore = $flowStore
if (editableSchemaForm) {
editableSchemaForm.updateJson()
}
}}
on:addNew={(e) => {
handleEditSchema('inputEditor')
editableSchemaForm?.openField(e.detail)
$flowStore = $flowStore
}}
>
<svelte:fragment slot="trigger">
@@ -378,9 +464,7 @@
<svelte:fragment slot="extraTab">
{#if $flowInputEditorState?.selectedTab === 'history'}
<FlowInputEditor
disabled={!payloadData}
on:applySchemaAndArgs={applySchemaAndArgs}
on:applySchema={applySchema}
title="History"
on:destroy={() => {
updatePreviewSchemaAndArgs(undefined)
}}
@@ -396,12 +480,10 @@
</FlowInputEditor>
{:else if $flowInputEditorState?.selectedTab === 'captures'}
<FlowInputEditor
disabled={!payloadData}
on:applySchemaAndArgs={applySchemaAndArgs}
on:applySchema={applySchema}
on:destroy={() => {
updatePreviewSchemaAndArgs(undefined)
}}
title="Trigger captures"
>
<svelete:fragment slot="action">
<div class="center-center">
@@ -417,13 +499,10 @@
</FlowInputEditor>
{:else if $flowInputEditorState?.selectedTab === 'savedInputs'}
<FlowInputEditor
{preventEnter}
disabled={!payloadData}
on:applySchemaAndArgs={applySchemaAndArgs}
on:applySchema={applySchema}
on:destroy={() => {
updatePreviewSchemaAndArgs(undefined)
}}
title="Saved inputs"
>
<SavedInputsPicker
flowPath={initialPath}
@@ -438,13 +517,10 @@
</FlowInputEditor>
{:else if $flowInputEditorState?.selectedTab === 'json'}
<FlowInputEditor
{preventEnter}
disabled={!jsonValid}
on:applySchemaAndArgs={applySchemaAndArgs}
on:applySchema={applySchema}
on:destroy={() => {
updatePreviewSchemaAndArgs(undefined)
}}
title="Json payload"
>
<SimpleEditor
on:focus={() => {
@@ -470,22 +546,25 @@
</FlowInputEditor>
{:else if $flowInputEditorState?.selectedTab === 'firstStepInputs'}
<FlowInputEditor
disabled={!previewSchema}
on:applySchemaAndArgs={() => {
applySchemaAndArgs()
connectFirstNode()
}}
on:applySchema={applySchema}
on:destroy={() => {
updatePreviewSchemaAndArgs(undefined)
connectFirstNode = () => {}
}}
title="First step's inputs"
>
<FirstStepInputs
on:connectFirstNode={({ detail }) => {
connectFirstNode = detail.connectFirstNode
}}
on:select={(e) => {
previewSchema = e.detail ?? undefined
if (e.detail) {
const diffSchema = computeDiff(e.detail, $flowStore.schema)
diff = diffSchema
previewSchema = schemaFromDiff(diffSchema, $flowStore.schema)
runDisabled = true
} else {
updatePreviewSchemaAndArgs(undefined)
}
}}
/>
</FlowInputEditor>
@@ -496,7 +575,7 @@
<Button
color="dark"
btnClasses="w-fit"
disabled={!!previewSchema}
disabled={runDisabled}
size="xs"
shortCut={{ Icon: CornerDownLeft, hide: false }}
on:click={() => {
@@ -1,51 +1,26 @@
<script lang="ts">
import { createEventDispatcher, onDestroy } from 'svelte'
import Section from '$lib/components/Section.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { CornerDownLeft, Save } from 'lucide-svelte'
export let name: string = ''
export let disabled = false
export let preventEnter = false
const dispatch = createEventDispatcher()
function applySchemaAndArgs() {
dispatch('applySchemaAndArgs')
}
export let title: string = ''
onDestroy(() => {
dispatch('destroy')
})
</script>
<svelte:window
on:keydown={(e) => {
if (e.key === 'Enter' && !preventEnter) {
applySchemaAndArgs()
e.preventDefault()
}
}}
/>
<div class="flex flex-col h-full px-3 pb-3">
<div
class="items-center grow-0 flex flex-row justify-between gap-2 data-schema-picker min-h-[40px]"
>
<h2 class="font-semibold text-secondary text-sm flex flex-row items-center gap-1 leading-6">
{title}
</h2>
<slot name="action" />
</div>
<div class="h-full p-2">
<Section label={name} class="h-full" small={true}>
<svelte:fragment slot="header">
<slot name="header" />
</svelte:fragment>
<svelte:fragment slot="action">
<div class="flex flex-row gap-2 data-schema-picker">
<slot name="action" />
<Button
size="xs2"
color="dark"
{disabled}
shortCut={{ Icon: CornerDownLeft, hide: false, withoutModifier: true }}
startIcon={{ icon: Save }}
on:click={applySchemaAndArgs}>Update schema</Button
>
</div>
</svelte:fragment>
<div class="w-full min-h-0 grow">
<slot />
</Section>
</div>
</div>
@@ -0,0 +1,69 @@
<script lang="ts">
export let dropdownItems: Array<{
label: string
onClick: () => void
icon?: any
selected?: boolean
}> = []
export let fullMenu: boolean = false
let open = false
let timeout: NodeJS.Timeout | null = null
function handleMouseEnter() {
if (!fullMenu) return
timeout = setTimeout(() => {
open = true
}, 500)
}
function handleMouseLeave() {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
open = false
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="flex flex-col relative !overflow-visible"
on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave}
>
<slot name="close button" />
{#if fullMenu}
<div
class="absolute flex-col top-[30px] left-0 z-50 bg-surface border-l border-b {open
? 'rounded-md rounded-tl-none overflow-hidden shadow-md'
: 'rounded-bl-md'}"
>
{#each dropdownItems as item}
<button
class="hover:bg-surface-hover p-2 transition-colors duration-150 w-full {item.selected
? 'bg-surface-selected'
: 'text-secondary'}"
on:click={() => {
item.onClick()
open = false
}}
>
<div class="flex flex-row items-center gap-2 min-h-[20px]">
{#if item.icon}
<svelte:component this={item.icon} size={14} />
{/if}
{#if open}
<p class="text-xs text-secondary whitespace-nowrap text-left">
{item.label}
</p>
{/if}
</div>
</button>
{/each}
</div>
{/if}
</div>
@@ -138,7 +138,9 @@
if (Object.keys(modifiedProperties).includes(argName)) {
delete modifiedProperties[argName]
modifiedObject.required = schema.required.filter((arg) => arg !== argName)
if (modifiedObject.required) {
modifiedObject.required = schema.required.filter((arg) => arg !== argName)
}
if (modifiedObject.order) {
modifiedObject.order = modifiedObject.order.filter((arg) => arg !== argName)
}
@@ -7,7 +7,6 @@
} from '$lib/common'
import { emptySchema, sendUserToast } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
import SimpleEditor from '../SimpleEditor.svelte'
import AddPropertyFormV2 from './AddPropertyFormV2.svelte'
export let schema: Schema | any = emptySchema()
@@ -24,13 +23,10 @@
schema = emptySchema()
}
let schemaString: string = ''
// Internal state: bound to args builder modal
let argError = ''
let editing = false
let oldArgName: string | undefined // when editing argument and changing name
let jsonEditor: SimpleEditor
reorder()
@@ -59,7 +55,7 @@
}
}
function handleAddOrEditArgument(modalProperty: ModalSchemaProperty): void {
export function handleAddOrEditArgument(modalProperty: ModalSchemaProperty): void {
// If editing the arg's name, oldName containing the old argument name must be provided
argError = ''
modalProperty.name = modalProperty.name.trim()
@@ -109,8 +105,6 @@
}
schema = schema
schemaString = JSON.stringify(schema, null, '\t')
jsonEditor?.setCode(schemaString)
if (argError !== '') {
sendUserToast(argError, true)
@@ -137,12 +131,13 @@
if (Object.keys(modifiedProperties).includes(argName)) {
delete modifiedProperties[argName]
modifiedObject.required = schema.required.filter((arg) => arg !== argName)
if (modifiedObject.required) {
modifiedObject.required = schema.required.filter((arg) => arg !== argName)
}
if (modifiedObject.order) {
modifiedObject.order = modifiedObject.order.filter((arg) => arg !== argName)
}
schema = schema
schemaString = JSON.stringify(schema, null, '\t')
dispatch('change', schema)
} else {
throw Error('Argument not found!')
@@ -163,6 +158,7 @@
selectedType: 'string',
name: e.detail.name
})
dispatch('addNew', e.detail.name)
} catch (err) {
sendUserToast(`Could not add argument: ${err}`, true)
}
@@ -154,8 +154,11 @@
<div class="flex flex-col w-full mt-2">
<Label label="Nested properties">
<svelte:self
on:change={() => (schema = schema)}
schema={schema.properties[item.value]}
on:change={() => {
schema = schema
dispatch('change', schema)
}}
bind:schema={schema.properties[item.value]}
parentId={item.value}
/>
</Label>
@@ -187,7 +190,13 @@
editTab="inputEditor"
>
<svelte:fragment slot="addProperty">
<AddPropertyV2 bind:schema on:change>
<AddPropertyV2
bind:schema
on:change
on:addNew={(e) => {
editableSchemaForm?.openField(e.detail)
}}
>
<svelte:fragment slot="trigger">
<div
class="w-full py-2 flex justify-center items-center border border-dashed rounded-md hover:bg-surface-hover"
@@ -19,6 +19,7 @@
let resourceIsTextFile: boolean = false
let addProperty: AddPropertyV2 | undefined = undefined
let editableSchemaForm: EditableSchemaForm | undefined = undefined
const dispatch = createEventDispatcher()
@@ -90,6 +91,9 @@
bind:schema
bind:this={addProperty}
on:change={() => dispatch('change', schema)}
on:addNew={(e) => {
editableSchemaForm?.openField(e.detail)
}}
>
<svelte:fragment slot="trigger">
<div
@@ -101,6 +105,7 @@
</AddPropertyV2>
{/if}
<EditableSchemaForm
bind:this={editableSchemaForm}
bind:schema
on:change={() => dispatch('change', schema)}
isFlowInput
@@ -5,7 +5,7 @@
import { GripVertical } from 'lucide-svelte'
import type { Schema } from '$lib/common'
import { deepEqual } from 'fast-equals'
import type { SchemaDiff } from '$lib/components/schema/schemaUtils'
export let dndType: string | undefined = undefined
export let schema: Schema
export let args: Record<string, any> = {}
@@ -15,6 +15,11 @@
export let disablePortal: boolean = false
export let disabled: boolean = false
export let schemaSkippedValues: string[] = []
export let nestedParent: { label: string; nestedParent: any | undefined } | undefined = undefined
export let disableDnd: boolean = false
export let shouldDispatchChanges: boolean = false
export let diff: Record<string, SchemaDiff> = {}
export let nestedClasses = ''
const dispatch = createEventDispatcher()
const flipDurationMs = 200
@@ -66,12 +71,16 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<SchemaForm
{nestedClasses}
{schemaSkippedValues}
on:click
on:change
on:reorder
on:consider={handleConsider}
on:finalize={handleFinalize}
on:acceptChange
on:rejectChange
on:nestedChange
{lightweightMode}
bind:args
{prettifyHeader}
@@ -79,17 +88,24 @@
{disablePortal}
{disabled}
bind:schema
dndConfig={{
items,
flipDurationMs,
dropTargetStyle: {},
type: dndType ?? 'top-level'
}}
dndConfig={disableDnd
? undefined
: {
items,
flipDurationMs,
dropTargetStyle: {},
type: dndType ?? 'top-level'
}}
{items}
{diff}
{nestedParent}
{shouldDispatchChanges}
>
<svelte:fragment slot="actions">
<div class="w-4 h-8 cursor-move ml-2 handle" aria-label="drag-handle" use:dragHandle>
<GripVertical size={16} />
</div>
{#if !disableDnd}
<div class="w-4 h-8 cursor-move ml-2 handle" aria-label="drag-handle" use:dragHandle>
<GripVertical size={16} />
</div>
{/if}
</svelte:fragment>
</SchemaForm>
@@ -0,0 +1,217 @@
export type SchemaDiff = {
diff: 'same' | 'added' | 'removed' | 'modified' | Record<string, SchemaDiff>
fullSchema: { [key: string]: any } | undefined
oldSchema?: { [key: string]: any } | undefined
}
function isCompatible(diff: Record<string, SchemaDiff>) {
let compatible = true
Object.values(diff).forEach((diff) => {
if (diff.diff === 'added' || diff.diff === 'modified' || diff.diff === 'removed') {
compatible = false
} else if (isRecordSchemaDiff(diff.diff)) {
compatible = isCompatible(diff.diff)
}
})
return compatible
}
function isCompatibleObject(a: any, b: any): boolean {
if (!a || !b) {
return false
}
if (a.type === 'null' || b.type === 'null') {
return true
}
if (a.type !== b.type) {
if (
(a.type === 'number' || a.type === 'integer') &&
(b.type === 'number' || b.type === 'integer')
) {
return true
}
return false
}
switch (a.type) {
case 'object':
if (a.oneOf || b.oneOf) {
//TODO: handle oneOf compatibility. here we assume that only b is oneOf
return true
}
return a.format === b.format
case 'array':
return !a.items || !b.items || a.items?.type === b.items?.type
case 'string':
return a.format === b.format
case 'boolean':
case 'number':
case 'integer':
return true
default:
return false
}
}
export function computeDiff(
previewSchema: { [key: string]: any } | undefined,
currentSchema: { [key: string]: any } | undefined
) {
if (!previewSchema || !currentSchema) {
return {}
}
const diff: Record<string, SchemaDiff> = {}
if (previewSchema?.properties) {
Object.keys(previewSchema.properties).forEach((key) => {
if (!currentSchema?.properties?.[key]) {
diff[key] = {
diff: 'added',
fullSchema: previewSchema.properties[key]
}
} else {
const previewProp = previewSchema.properties[key]
const currentProp = currentSchema.properties[key]
if (previewProp.type === 'object' && currentProp.type === 'object') {
if (JSON.stringify(previewProp) === JSON.stringify(currentProp)) {
diff[key] = { diff: 'same', fullSchema: undefined }
} else if (previewProp.oneOf || currentProp.oneOf) {
if (isCompatibleObject(previewProp, currentProp)) {
diff[key] = { diff: 'same', fullSchema: undefined }
} else {
diff[key] = { diff: 'modified', fullSchema: previewProp, oldSchema: currentProp }
}
} else if (previewProp.format || currentProp.format) {
//TODO: handle s3 object compatibility
diff[key] = { diff: 'modified', fullSchema: previewProp, oldSchema: currentProp }
} else {
const diffProp = computeDiff(previewProp, currentProp)
const checkIfSame = isCompatible(diffProp)
if (checkIfSame) {
diff[key] = { diff: 'same', fullSchema: undefined }
} else {
diff[key] = {
diff: diffProp,
fullSchema: previewProp,
oldSchema: currentProp
}
}
}
} else if (isCompatibleObject(previewProp, currentProp)) {
diff[key] = { diff: 'same', fullSchema: undefined }
} else {
diff[key] = { diff: 'modified', fullSchema: previewProp, oldSchema: currentProp }
}
}
})
}
if (currentSchema?.properties) {
Object.keys(currentSchema.properties).forEach((key) => {
if (!previewSchema?.properties?.[key]) {
diff[key] = { diff: 'removed', fullSchema: undefined }
}
})
}
return diff
}
export function schemaFromDiff(
diff: Record<string, SchemaDiff>,
schema: { [key: string]: any } | undefined
) {
if (!schema) {
return undefined
}
const newSchema = structuredClone(schema)
Object.keys(diff).forEach((key) => {
const diffValue = diff[key].diff
if (diffValue === 'added' || diffValue === 'modified') {
newSchema.properties[key] = diff[key].fullSchema
if (newSchema.order && !newSchema.order.includes(key)) {
newSchema.order.push(key)
}
} else if (isRecordSchemaDiff(diffValue)) {
// Handle nested diffs
newSchema.properties[key] = schemaFromDiff(diffValue, schema.properties[key])
}
})
return newSchema
}
export function getFullPath(arg: { label: string; nestedParent: any | undefined }): string[] {
const getPath = (current: { label: string; nestedParent: any | undefined }): string[] => {
if (!current.nestedParent) {
return [current.label]
}
return [...getPath(current.nestedParent), current.label]
}
return getPath(arg)
}
export function getNestedProperty(obj: any, path: string[], field: string = 'properties') {
return path.reduce((curr, key) => curr?.[field]?.[key], obj)
}
export function setNestedProperty(
obj: any,
path: string[],
value: any,
field: string = 'properties'
) {
const pathCopy = [...path]
const lastKey = pathCopy.pop()
const target = pathCopy.reduce((curr, key) => {
if (!(key in curr[field])) {
curr[field][key] = { [field]: {} }
}
return curr[field][key]
}, obj)
if (lastKey && value) {
const newValue = structuredClone(value)
target[field][lastKey] = newValue
return
} else if (lastKey && !value) {
delete target[field][lastKey]
}
}
function isRecordSchemaDiff(value: SchemaDiff['diff']): value is Record<string, SchemaDiff> {
return typeof value === 'object' && value !== null
}
export function applyDiff(
schema: Record<string, any> | undefined,
diff: Record<string, SchemaDiff> | undefined
) {
if (!diff || !schema) {
return
}
let newSchema = structuredClone(schema)
Object.keys(diff).forEach((key) => {
const diffValue = diff[key].diff
if (diffValue === 'removed') {
delete newSchema.properties[key]
if (newSchema.order) {
newSchema.order = newSchema.order.filter((x) => x !== key)
}
} else if (diffValue === 'added' || diffValue === 'modified' || isRecordSchemaDiff(diffValue)) {
newSchema.properties[key] = diff[key].fullSchema
if (newSchema.order && !newSchema.order.includes(key)) {
newSchema.order.push(key)
}
}
})
return newSchema
}
@@ -133,6 +133,8 @@
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && selected) {
deselect()
event.stopPropagation()
event.preventDefault()
}
}
@@ -149,7 +151,7 @@
<svelte:window on:keydown={handleKeydown} />
<Label label="Trigger captures" {headless} class="h-full flex flex-col gap-1">
<Label label="Trigger captures" {headless} class="h-full {headless ? '' : 'flex flex-col gap-1'}">
<svelte:fragment slot="header">
{#if addButton}
<div class="inline-block">
@@ -180,7 +182,13 @@
</svelte:fragment>
<div
class={fullHeight ? 'h-full' : capturesLength > 7 ? 'h-[300px]' : 'h-fit'}
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) {