Files
windmill/frontend/src/lib/components/schema/FlowPropertyEditor.svelte
T

476 lines
12 KiB
Svelte

<script lang="ts">
import Alert from '$lib/components/common/alert/Alert.svelte'
import Label from '../Label.svelte'
import Toggle from '../Toggle.svelte'
import SimpleEditor from '../SimpleEditor.svelte'
import type ItemPicker from '../ItemPicker.svelte'
import type VariableEditor from '../VariableEditor.svelte'
import { createEventDispatcher } from 'svelte'
import ArgInput from '../ArgInput.svelte'
import ResourceNarrowing from '../ResourceNarrowing.svelte'
import EditableSchemaDrawer from './EditableSchemaDrawer.svelte'
import type { SchemaProperty } from '$lib/common'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import Button from '../common/button/Button.svelte'
import { Pen, Plus, Trash2 } from 'lucide-svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import ResourcePicker from '../ResourcePicker.svelte'
import { DynamicInput } from '$lib/utils'
interface Props {
format?: string | undefined
contentEncoding?: 'base64' | 'binary' | undefined
type?: string | undefined
oneOf?: SchemaProperty[] | undefined
required?: boolean
pattern?: undefined | string
password?: undefined | boolean
variableEditor?: VariableEditor | undefined
itemPicker?: ItemPicker | undefined
nullable?: boolean | undefined
disabled?: boolean | undefined
defaultValue?: any
propsNames?: any
showExpr?: string | undefined
extra?: Record<string, any>
customErrorMessage?: string | undefined
itemsType?:
| {
type?: 'string' | 'number' | 'bytes' | 'object'
contentEncoding?: 'base64'
enum?: string[]
multiselect?: string[]
}
| undefined
properties?: Record<string, any> | undefined
order?: string[] | undefined
requiredProperty?: string[] | undefined
displayWebhookWarning?: boolean
onDrawerClose?: () => void
}
let {
format = $bindable(undefined),
contentEncoding = undefined,
type = undefined,
oneOf = $bindable(undefined),
required = false,
pattern = undefined,
password = undefined,
variableEditor = undefined,
itemPicker = undefined,
nullable = $bindable(undefined),
disabled = $bindable(undefined),
defaultValue = $bindable(undefined),
propsNames = [],
showExpr = $bindable(undefined),
extra = {},
customErrorMessage = undefined,
itemsType = undefined,
properties = $bindable(undefined),
order = $bindable(undefined),
requiredProperty = $bindable(undefined),
displayWebhookWarning = true,
onDrawerClose = undefined
}: Props = $props()
let oneOfSelected: string | undefined = $state(oneOf?.[0]?.title)
$effect(() => {
if (oneOf?.length && !oneOfSelected) {
oneOfSelected = oneOf[0].title
}
})
const dispatch = createEventDispatcher()
function getResourceTypesFromFormat(format: string | undefined): string[] {
if (format?.startsWith('resource-')) {
return [format.split('-')[1]]
}
return []
}
let variantName = $state('')
function createVariant(name: string) {
if (oneOf) {
if (oneOf.some((obj) => obj.title === name)) {
throw new Error('Variant name already exists')
}
const idx = oneOf.findIndex((obj) => obj.title === name)
if (idx === -1) {
oneOf = [
...oneOf,
{
title: name,
type: 'object',
properties: {}
}
]
oneOfSelected = name
}
variantName = ''
}
}
function renameVariant(name: string, selected: string) {
if (oneOf) {
if (oneOf.some((obj) => obj.title === name)) {
throw new Error('Variant name already exists')
}
const idx = oneOf.findIndex((obj) => obj.title === selected)
if (idx !== -1) {
oneOf[idx].title = name
oneOfSelected = name
}
variantName = ''
}
}
let initialObjectSelected = $state(
format === 'json-schema'
? 'json-schema'
: format?.startsWith('jsonschema-')
? 'custom-object'
: Object.keys(properties ?? {}).length == 0
? 'resource'
: 'custom-object'
)
let isDyn = $derived(DynamicInput.isDynInputFormat(format))
let customObjectSelected: 'editor' | 'json-schema-resource' = $state(
format?.startsWith('jsonschema-') ? 'json-schema-resource' : 'editor'
)
</script>
<div class="flex flex-col gap-2 mt-2">
{#if type === 'object' && oneOf && oneOf.length >= 2}
<Label label="OneOf properties">
<div class="flex flex-row gap-1 items-center justify-start pt-2">
<ToggleButtonGroup
bind:selected={oneOfSelected}
class="h-auto w-auto"
tabListClass="flex-wrap"
>
{#snippet children({ item })}
{#each oneOf ?? [] as obj}
<ToggleButton value={obj.title ?? ''} label={obj.title} {item} />
{/each}
{/snippet}
</ToggleButtonGroup>
<Popover placement="bottom-end" closeButton>
{#snippet trigger()}
<Button size="xs2" color="light" nonCaptureEvent startIcon={{ icon: Plus }} />
{/snippet}
{#snippet content({ close })}
<Label label="Label" class="p-2 flex flex-col gap-2">
<input
type="text"
class="w-full !bg-surface"
onkeydown={(event) => {
if (event.key === 'Enter') {
createVariant(variantName)
close()
}
}}
bind:value={variantName}
/>
<Button
variant="default"
size="xs"
on:click={() => {
createVariant(variantName)
close()
}}
disabled={variantName.length === 0}
>
Add
</Button>
</Label>
{/snippet}
</Popover>
</div>
<div class="flex flex-row gap-2 items-center ml-1 mt-4 mb-2">
<span class="font-semibold text-sm">{oneOfSelected}</span>
<Popover
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
containerClasses="border rounded-lg shadow-lg p-4 bg-surface"
closeButton
>
{#snippet trigger()}
<Button
size="xs2"
color="light"
startIcon={{ icon: Pen }}
propagateEvent
iconOnly={false}
on:click={() => {
if (oneOfSelected) {
variantName = oneOfSelected
}
}}
/>
{/snippet}
{#snippet content({ close })}
<Label label="Label" class="p-2 flex flex-col gap-2">
<input
type="text"
class="w-full !bg-surface"
onkeydown={(event) => {
if (event.key === 'Enter') {
if (oneOfSelected) {
renameVariant(variantName, oneOfSelected)
close()
}
}
}}
bind:value={variantName}
/>
<Button
variant="default"
size="xs"
on:click={() => {
if (oneOfSelected) {
renameVariant(variantName, oneOfSelected)
close()
}
}}
disabled={variantName.length === 0}
>
Rename
</Button>
</Label>
{/snippet}
</Popover>
<Button
size="xs2"
color="red"
startIcon={{ icon: Trash2 }}
iconOnly
disabled={(oneOf?.length ?? 0) <= 2}
on:click={() => {
if (oneOf && oneOfSelected) {
const idx = oneOf.findIndex((obj) => obj.title === oneOfSelected)
oneOf = oneOf.filter((_, i) => i !== idx)
oneOfSelected = oneOf[0].title
}
}}
/>
</div>
{#if oneOfSelected && oneOf}
{@const idx = oneOf.findIndex((obj) => obj.title === oneOfSelected)}
<div class="ml-1">
<EditableSchemaDrawer
onClose={() => {
onDrawerClose?.()
}}
bind:schema={oneOf[idx]}
hiddenArgs={[
oneOf?.find((o) => Object.keys(o.properties ?? {}).includes('kind'))
? 'kind'
: 'label'
]}
/>
</div>
{/if}
</Label>
<div class="py-2"></div>
{:else if type === 'object' && format?.startsWith('resource-') && format !== 'resource-s3_object' && !isDyn}
<ResourceNarrowing bind:format />
{:else if type === 'object' && !format?.startsWith('resource-') && !isDyn}
<div class="py-2">
<Label label="Object properties">
<ToggleButtonGroup
bind:selected={customObjectSelected}
class="my-2"
on:selected={(e) => {
if (e.detail === 'editor') {
format = undefined
} else {
properties = undefined
order = undefined
requiredProperty = undefined
}
}}
>
{#snippet children({ item })}
<ToggleButton value="editor" label="Custom" {item} />
<ToggleButton
value="json-schema-resource"
label="Template"
{item}
tooltip="Select a JSON schema resource to specify the object's properties"
showTooltipIcon
/>
{/snippet}
</ToggleButtonGroup>
{#if customObjectSelected === 'editor'}
<EditableSchemaDrawer
bind:schema={
() => {
return {
properties: properties,
order: order,
required: requiredProperty
}
},
(v) => {
properties = v.properties
order = v.order
requiredProperty = v.required
}
}
/>
{:else if customObjectSelected === 'json-schema-resource'}
{#if format == undefined}
<div class="text-xs text-primary my-1">
Select a <code>json_schema</code> resource as a reusable JSON schema template
</div>
{/if}
<ResourcePicker
resourceType="json_schema"
bind:value={
() => {
if (format?.startsWith('jsonschema-')) {
return format.substring('jsonschema-'.length)
}
return undefined
},
(v) => {
if (v) {
format = 'jsonschema-' + v
} else {
format = undefined
}
}
}
/>
{/if}
</Label>
</div>
{/if}
{#if !(type === 'object' && oneOf && oneOf.length >= 2) && !(type == 'object' && initialObjectSelected == 'custom-object') && !isDyn}
<Label label="Default">
<ArgInput
noDefaultOnSelectFirst
{itemPicker}
resourceTypes={getResourceTypesFromFormat(format)}
bind:value={defaultValue}
type={password ? 'string' : type}
displayHeader={false}
{pattern}
{customErrorMessage}
{itemsType}
{contentEncoding}
{format}
{extra}
{disabled}
{nullable}
{variableEditor}
compact
noMargin
/>
</Label>
{/if}
<div class="flex flex-row gap-2 flex-wrap h-auto">
<Toggle
options={{ right: 'Required' }}
size="xs"
disabled={type === 'boolean'}
on:change={(event) => {
dispatch('requiredChange', { required: event?.detail })
}}
checked={required}
on:change={(event) => {
if (event?.detail) {
nullable = false
}
}}
/>
{#if type === 'string'}
<Toggle
options={{
right: 'Nullable',
rightTooltip: 'If enabled, the default value will be null and not an empty string.'
}}
lightMode
size="xs"
checked={nullable}
on:change={(event) => {
if (event?.detail) {
nullable = true
} else {
nullable = undefined
}
}}
disabled={required}
/>
{/if}
<Toggle
options={{
right: 'Disabled',
rightTooltip: 'Do not let user modify this field'
}}
lightMode
size="xs"
checked={disabled}
on:change={(event) => {
if (event?.detail) {
disabled = true
} else {
disabled = undefined
}
}}
/>
</div>
{#if displayWebhookWarning && !(type === 'object' && oneOf && oneOf.length >= 2)}
<Alert type="info" title="Default not used by webhooks" size="xs" collapsible>
If this flow is triggered by a webhook, the default value will not replace a missing value
from the payload. It will only be used as the default value in the autogenerated UI. We
recommend using default values in the signature of the steps where this value is used (using
`x=default`) to have the desired behavior.
</Alert>
{/if}
</div>
<div>
<Toggle
size="xs"
options={{ right: 'Show this field only when conditions are met' }}
checked={Boolean(showExpr)}
on:change={() => {
showExpr = showExpr ? undefined : 'true //fields.foo == 42'
}}
/>
{#if showExpr}
<div class="mt-2 bg-surface-tertiary rounded-md pl-3">
<SimpleEditor
small
yPadding={7}
extraLib={`declare const fields: Record<${propsNames
?.filter((x) => x != name)
.map((x) => `"${x}"`)
.join(' | ')}, any>;\n`}
lang="javascript"
bind:code={showExpr}
shouldBindKey={false}
fixedOverflowWidgets={false}
hideLineNumbers
autoHeight
/>
</div>
<div class="flex flex-row-reverse text-2xs text-primary">
<div>
Other fields are available under <code>fields</code> (e.g:
<code>fields.foo == 42</code>)
</div>
</div>
{/if}
</div>