Files
windmill/frontend/src/lib/components/schema/FlowPropertyEditor.svelte
2024-09-28 18:10:42 +02:00

443 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 ObjectTypeNarrowing from '../ObjectTypeNarrowing.svelte'
import Tabs from '../common/tabs/Tabs.svelte'
import { Tab, TabContent } from '../common'
import LightweightArgInput from '../LightweightArgInput.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 Popup from '../common/popup/Popup.svelte'
import { deepEqual } from 'fast-equals'
export let format: string | undefined = undefined
export let contentEncoding: 'base64' | 'binary' | undefined = undefined
export let type: string | undefined = undefined
export let oneOf: SchemaProperty[] | undefined = undefined
export let required = false
export let pattern: undefined | string = undefined
export let password = false
export let variableEditor: VariableEditor | undefined = undefined
export let itemPicker: ItemPicker | undefined = undefined
export let nullable: boolean = false
export let disabled: boolean = false
export let defaultValue: any = undefined
export let propsNames: any = []
export let showExpr: string | undefined = undefined
export let extra: Record<string, any> = {}
export let customErrorMessage: string | undefined = undefined
export let itemsType:
| {
type?: 'string' | 'number' | 'bytes' | 'object'
contentEncoding?: 'base64'
enum?: string[]
multiselect?: string[]
}
| undefined = undefined
export let properties: Record<string, any> = {}
export let order: string[] = []
export let requiredProperty: string[] = []
export let displayWebhookWarning: boolean = true
export let lightweightMode: boolean = false
function getOneOfWithoutLabel(oneOf: SchemaProperty[]) {
return oneOf.map((v) => ({
...v,
properties: Object.fromEntries(
Object.entries(v.properties ?? {}).filter(([k, v]) => k !== 'label')
)
}))
}
let oneOfSelected: string | undefined = undefined
function oneOfUpdate(oneOf: SchemaProperty[] | undefined) {
if (oneOf && oneOf.length >= 2) {
if (!oneOfSelected) {
oneOfSelected = oneOf[0].title
}
if (
!schema.oneOf ||
!deepEqual(
oneOf.map((v) => [v.title, v.order]),
schema.oneOf.map((v) => [v.title, v.order])
)
) {
// update schema if not exists or order changed
schema.oneOf = getOneOfWithoutLabel(oneOf)
schema = schema
}
} else if (!oneOf) {
schema.oneOf = undefined
schema = schema
}
}
$: oneOfUpdate(oneOf)
function orderUpdate(order) {
if (order && !deepEqual(order, schema.order)) {
// update from external reordering
schema.order = order
schema = schema
}
}
$: orderUpdate(order)
const dispatch = createEventDispatcher()
function getResourceTypesFromFormat(format: string | undefined): string[] {
if (format?.startsWith('resource-')) {
return [format.split('-')[1]]
}
return []
}
let schema = {
properties,
order,
required: requiredProperty,
oneOf: oneOf ? getOneOfWithoutLabel(oneOf) : undefined
}
function schemaUpdate(changedSchema: typeof schema) {
if (
!deepEqual(changedSchema, {
properties,
order,
required: requiredProperty,
oneOf: oneOf ? getOneOfWithoutLabel(oneOf) : undefined
})
) {
properties = structuredClone(changedSchema.properties)
order = structuredClone(changedSchema.order)
requiredProperty = structuredClone(changedSchema.required)
oneOf = changedSchema.oneOf?.map((v) => {
return {
...v,
properties: {
...v.properties,
label: {
type: 'string',
enum: [v.title ?? '']
}
}
}
})
dispatch('schemaChange', { properties, order, requiredProperty, oneOf })
}
}
$: schemaUpdate(schema)
let variantName = ''
function createVariant(name: string) {
if (schema.oneOf) {
if (schema.oneOf.some((obj) => obj.title === name)) {
throw new Error('Variant name already exists')
}
const idx = schema.oneOf.findIndex((obj) => obj.title === name)
if (idx === -1) {
schema.oneOf = [
...schema.oneOf,
{
title: name,
type: 'object',
properties: {}
}
]
oneOfSelected = name
}
variantName = ''
}
}
function renameVariant(name: string, selected: string) {
if (schema.oneOf) {
if (schema.oneOf.some((obj) => obj.title === name)) {
throw new Error('Variant name already exists')
}
const idx = schema.oneOf.findIndex((obj) => obj.title === selected)
if (idx !== -1) {
schema.oneOf[idx].title = name
oneOfSelected = name
}
variantName = ''
}
}
let initialObjectSelected =
Object.keys(schema?.properties ?? {}).length == 0 ? 'resource' : 'custom-object'
</script>
<div class="flex flex-col gap-2">
{#if type === 'object' && schema.oneOf && schema.oneOf.length >= 2}
<div class="flex flex-row gap-2 items-center justify-start">
<ToggleButtonGroup bind:selected={oneOfSelected} class="w-auto">
{#each schema.oneOf as obj}
<ToggleButton value={obj.title} label={obj.title} />
{/each}
</ToggleButtonGroup>
<Popup
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
containerClasses="border rounded-lg shadow-lg p-4 bg-surface"
let:close
>
<svelte:fragment slot="button">
<Button size="xs2" color="light" nonCaptureEvent startIcon={{ icon: Plus }} />
</svelte:fragment>
<Label label="Label">
<div class="flex flex-col gap-2">
<input
type="text"
class="w-full !bg-surface"
on:keydown={(event) => {
if (event.key === 'Enter') {
createVariant(variantName)
close(null)
}
}}
bind:value={variantName}
/>
<Button
variant="border"
color="light"
size="xs"
on:click={() => {
createVariant(variantName)
close(null)
}}
disabled={!variantName}
>
Add
</Button>
</div>
</Label>
</Popup>
</div>
<div class="flex flex-row gap-2 items-center">
<span class="font-semibold text-sm">{oneOfSelected}</span>
<Popup
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
containerClasses="border rounded-lg shadow-lg p-4 bg-surface"
let:close
>
<svelte:fragment slot="button">
<Button
size="xs2"
color="light"
startIcon={{ icon: Pen }}
propagateEvent
iconOnly={false}
on:click={() => {
if (oneOfSelected) {
variantName = oneOfSelected
}
}}
/>
</svelte:fragment>
<Label label="Label">
<div class="flex flex-col gap-2">
<input
type="text"
class="w-full !bg-surface"
on:keydown={(event) => {
if (event.key === 'Enter') {
if (oneOfSelected) {
renameVariant(variantName, oneOfSelected)
close(null)
}
}
}}
bind:value={variantName}
/>
<Button
variant="border"
color="light"
size="xs"
on:click={() => {
if (oneOfSelected) {
renameVariant(variantName, oneOfSelected)
close(null)
}
}}
disabled={!variantName}
>
Rename
</Button>
</div>
</Label>
</Popup>
<Button
size="xs2"
color="red"
startIcon={{ icon: Trash2 }}
iconOnly
disabled={schema.oneOf.length <= 2}
on:click={() => {
if (schema.oneOf && oneOfSelected) {
const idx = schema.oneOf.findIndex((obj) => obj.title === oneOfSelected)
schema.oneOf = schema.oneOf.filter((_, i) => i !== idx)
oneOfSelected = schema.oneOf[0].title
}
}}
/>
</div>
{#if oneOfSelected && schema.oneOf}
{@const idx = schema.oneOf.findIndex((obj) => obj.title === oneOfSelected)}
<EditableSchemaDrawer bind:schema={schema.oneOf[idx]} />
{/if}
{:else if type === 'object' && format !== 'resource-s3_object'}
<Tabs
selected={initialObjectSelected}
on:selected={(e) => {
if (e.detail === 'custom-object') {
format = ''
}
}}
>
<Tab value="resource">Resource</Tab>
<Tab value="custom-object">Custom Object</Tab>
<svelte:fragment slot="content">
<div class="pt-2">
<TabContent value="custom-object">
<EditableSchemaDrawer bind:schema />
</TabContent>
<TabContent value="resource">
<ObjectTypeNarrowing bind:format />
</TabContent>
</div>
</svelte:fragment>
</Tabs>
{/if}
{#if !(type === 'object' && oneOf && oneOf.length >= 2)}
<Label label="Default">
{#if lightweightMode}
<LightweightArgInput
bind:value={defaultValue}
type={password ? 'string' : type}
displayHeader={false}
{pattern}
{customErrorMessage}
{itemsType}
{contentEncoding}
{format}
{extra}
{disabled}
/>
{:else}
<ArgInput
{itemPicker}
resourceTypes={getResourceTypesFromFormat(format)}
bind:value={defaultValue}
type={password ? 'string' : type}
displayHeader={false}
{pattern}
{customErrorMessage}
{itemsType}
{contentEncoding}
{format}
{extra}
{disabled}
{nullable}
{variableEditor}
compact
noMargin
/>
{/if}
</Label>
{/if}
<div class="flex flex-row gap-2">
<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"
bind:checked={nullable}
disabled={required}
/>
{/if}
<Toggle
options={{
right: 'Disabled',
rightTooltip: 'Do not let user modify this field'
}}
lightMode
size="xs"
bind:checked={disabled}
/>
</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 != undefined}
<div class="border">
<SimpleEditor
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}
autoHeight
/>
</div>
<div class="flex flex-row-reverse text-2xs text-tertiary">
<div>
Other fields are available under <code>fields</code> (e.g:
<code>fields.foo == 42</code>)
</div>
</div>
{/if}
</div>