Files
windmill/frontend/src/lib/components/EditableSchemaForm.svelte
2024-11-07 20:51:32 +01:00

561 lines
18 KiB
Svelte

<script lang="ts">
import type { Schema } from '$lib/common'
import { VariableService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Button } from './common'
import ItemPicker from './ItemPicker.svelte'
import VariableEditor from './VariableEditor.svelte'
import { Pen, Plus, X } from 'lucide-svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { twMerge } from 'tailwind-merge'
import FlowPropertyEditor from './schema/FlowPropertyEditor.svelte'
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'
import { sendUserToast } from '$lib/toast'
import Toggle from './Toggle.svelte'
import { emptyString } from '$lib/utils'
import Popup from './common/popup/Popup.svelte'
import SchemaFormDnd from './schema/SchemaFormDND.svelte'
import { deepEqual } from 'fast-equals'
export let schema: Schema | any
export let schemaSkippedValues: string[] = []
export let args: Record<string, any> = {}
export let shouldHideNoInputs: boolean = false
export let noVariablePicker = false
export let flexWrap = false
export let uiOnly: boolean = false
export let isFlowInput: boolean = false
export let noPreview: boolean = false
export let offset = 48 + 31 + 31 + 16 + 1
export let jsonEnabled: boolean = true
export let isAppInput: boolean = false
export let lightweightMode: boolean = false
export let displayWebhookWarning: boolean = false
export let dndType: string | undefined = undefined
const dispatch = createEventDispatcher()
let clazz: string = ''
export { clazz as class }
$: if (args == undefined || typeof args !== 'object') {
args = {}
}
export function setDefaults() {
const nargs = {}
Object.keys(schema?.properties ?? {}).forEach((key) => {
if (schema?.properties[key].default != undefined && args[key] == undefined) {
let value = schema?.properties[key].default
nargs[key] = value === 'object' ? structuredClone(value) : value
}
})
args = nargs
}
let pickForField: string | undefined
let itemPicker: ItemPicker | undefined = undefined
let variableEditor: VariableEditor | undefined = undefined
let keys: string[] = Array.isArray(schema?.order)
? [...schema.order]
: Object.keys(schema?.properties ?? {}) ?? Object.keys(schema?.properties ?? {})
$: schema && onSchemaChange()
function alignOrderWithProperties(schema: {
properties: Record<string, any>
order: string[] | undefined
}) {
if (schema.order == undefined && !Array.isArray(schema.order)) {
schema.order = []
}
let norder = [...schema.order]
let properties = Object.keys(schema?.properties ?? {})
let index = 0
let hasChanged = false
for (let k of properties) {
if (schema.properties[k].type === 'object' && schema.properties[k].properties) {
hasChanged = hasChanged || alignOrderWithProperties(schema.properties[k])
}
if (!norder.includes(k)) {
norder = [...norder.slice(0, index), k, ...norder.slice(index)]
}
index += 1
}
norder = norder.filter((x) => properties.includes(x))
if (!deepEqual(schema.order, norder)) {
schema.order = norder
return true
}
return hasChanged
}
function onSchemaChange() {
let editSchema = false
if (alignOrderWithProperties(schema)) {
editSchema = true
}
let lkeys = schema?.order ?? Object.keys(schema?.properties ?? {})
if (schema?.properties && !deepEqual(lkeys, keys)) {
keys = [...lkeys]
editSchema = true
if (opened == undefined) {
opened = keys[0]
}
}
if (editSchema) {
schema = schema
}
}
let opened: string | undefined = keys[0]
let selected = ''
export function openField(key: string) {
opened = key
}
export function deleteField(key: string) {
delete args[key]
delete schema.properties[key]
if (schema.required?.includes(key)) {
schema.required = schema.required?.filter((x) => x !== key)
}
if (schema.order) {
schema.order = schema.order.filter((x) => x !== key)
}
}
$: if (opened && schema.properties[opened]) {
selected = opened
? schema.properties[opened].type !== 'object'
? schema.properties[opened].type
: schema.properties[opened].format === 'resource-s3_object'
? 'S3'
: schema.properties[opened].oneOf && schema.properties[opened].oneOf.length >= 2
? 'oneOf'
: 'object'
: ''
}
function renameProperty(oldName: string, key: string) {
const el = document.getElementById(key) as HTMLInputElement
const newName = el.value
if (oldName === newName) {
return
}
if (Object.keys(schema.properties ?? {}).includes(newName)) {
sendUserToast('There is already an argument with this name', true)
// clear the input
el.value = oldName
} else {
args[newName] = args[oldName]
delete args[oldName]
schema.properties[newName] = schema.properties[oldName]
delete schema.properties[oldName]
if (schema.required?.includes(oldName)) {
schema.required = schema.required?.map((x) => (x === oldName ? newName : x))
}
// Replace the old name with the new name in the order array
if (schema.order) {
const index = schema.order.indexOf(oldName)
if (index !== -1) {
schema.order[index] = newName
}
}
opened = newName
schema = schema
dispatch('change', schema)
sendUserToast('Argument renamed successfully')
}
}
let jsonView: boolean = false
let schemaString: string = JSON.stringify(schema, null, '\t')
let error: string | undefined = undefined
let editor: SimpleEditor | undefined = undefined
</script>
<div style={offset ? `height: calc(100vh - ${offset}px);` : 'h-full'}>
<Splitpanes>
{#if !noPreview}
<Pane size={50} minSize={20}>
<div class="p-4">
<SchemaFormDnd
{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}
/>
</div>
</Pane>
{/if}
<Pane size={noPreview ? 100 : 50} minSize={noPreview ? 100 : 20}>
{#if jsonEnabled}
<div class="w-full p-2 flex justify-end">
<Toggle
bind:checked={jsonView}
label="JSON View"
size="xs"
options={{
right: 'JSON Editor',
rightTooltip:
'Arguments can be edited either using the wizard, or by editing their JSON Schema.'
}}
lightMode
on:change={() => {
schemaString = JSON.stringify(schema, null, '\t')
editor?.setCode(schemaString)
}}
/>
</div>
{/if}
{#if !jsonView}
<div class="w-full {clazz} {flexWrap ? 'flex flex-row flex-wrap gap-x-6 ' : ''} divide-y">
{#if keys.length > 0}
{#each keys as argName, i (argName)}
<div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class={twMerge(
'w-full flex bg-gray-50 dark:bg-gray-800 px-4 py-1 justify-between items-center hover:bg-gray-100 cursor-pointer',
opened === argName ? 'bg-gray-100 hover:bg-gray-200' : ''
)}
on:click={() => {
if (opened === argName) {
opened = undefined
} else {
opened = argName
}
}}
>
<div class="flex flex-row gap-2">
{argName}
{#if !uiOnly}
<div on:click|stopPropagation|preventDefault>
<Popup
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
containerClasses="border rounded-lg shadow-lg p-4 bg-surface"
let:close
>
<svelte:fragment slot="button">
<Button
color="light"
size="xs2"
nonCaptureEvent
startIcon={{ icon: Pen }}
iconOnly
/>
</svelte:fragment>
<Label label="Name">
<div class="flex flex-col gap-2">
<input
type="text"
class="w-full !bg-surface"
value={argName}
id={argName + i}
on:keydown={(event) => {
if (event.key === 'Enter') {
renameProperty(argName, argName + i)
close(null)
}
}}
/>
<Button
variant="border"
color="light"
size="xs"
on:click={() => {
renameProperty(argName, argName + i)
close(null)
}}
>
Rename
</Button>
</div>
</Label>
</Popup>
</div>
{/if}
</div>
{#if schema.required?.includes(argName)}
<span class="text-red-500 text-xs"> Required </span>
{/if}
{#if !uiOnly}
<button
class="rounded-full p-1 text-gray-500 bg-white
duration-200 hover:bg-gray-600 focus:bg-gray-600 hover:text-white dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800"
aria-label="Clear"
on:click={() => {
dispatch('delete', argName)
}}
>
<X size={16} />
</button>
{/if}
</div>
{#if opened === argName}
<div class="p-4 border-t">
{#if !schemaSkippedValues.includes(argName) && Object.keys(schema?.properties ?? {}).includes(argName)}
{#if typeof args == 'object' && schema?.properties[argName]}
<PropertyEditor
bind:description={schema.properties[argName].description}
type={schema.properties[argName].type}
bind:oneOf={schema.properties[argName].oneOf}
bind:pattern={schema.properties[argName].pattern}
bind:enum_={schema.properties[argName].enum}
bind:format={schema.properties[argName].format}
bind:contentEncoding={schema.properties[argName].contentEncoding}
bind:customErrorMessage={schema.properties[argName].customErrorMessage}
bind:itemsType={schema.properties[argName].items}
bind:extra={schema.properties[argName]}
bind:title={schema.properties[argName].title}
bind:placeholder={schema.properties[argName].placeholder}
bind:properties={schema.properties[argName].properties}
bind:order={schema.properties[argName].order}
{isFlowInput}
{isAppInput}
on:change={() => {
schema = schema
// console.log('schema', schema)
dispatch('change', schema)
}}
>
<svelte:fragment slot="typeeditor">
{#if isFlowInput || isAppInput}
<Label label="Type">
<ToggleButtonGroup
tabListClass="flex-wrap"
class="h-auto"
bind:selected
on:selected={(e) => {
const isS3 = e.detail == 'S3'
const isOneOf = e.detail == 'oneOf'
selected = e.detail
const emptyProperty = {
contentEncoding: undefined,
enum_: undefined,
pattern: undefined,
default: undefined,
min: undefined,
max: undefined,
currency: undefined,
currencyLocale: undefined,
multiselect: undefined,
password: undefined,
dateFormat: undefined,
...(e.detail == 'array' ? { items: { type: 'string' } } : {}),
showExpr: undefined,
nullable: undefined,
required: undefined
}
if (isS3) {
schema.properties[argName] = {
...emptyProperty,
type: 'object',
format: 'resource-s3_object'
}
} else if (isOneOf) {
schema.properties[argName] = {
...emptyProperty,
type: 'object',
oneOf: [
{
title: 'Option 1',
type: 'object',
properties: {
label: {
type: 'string',
enum: ['Option 1']
},
property_1: {
type: 'string'
}
}
},
{
title: 'Option 2',
type: 'object',
properties: {
label: {
type: 'string',
enum: ['Option 2']
},
property_2: {
type: 'string'
}
}
}
]
}
} else {
schema.properties[argName] = {
...emptyProperty,
format: undefined,
type: e.detail
}
}
schema = schema
dispatch('change', schema)
}}
>
{#each [['String', 'string'], ['Number', 'number'], ['Integer', 'integer'], ['Object', 'object'], ['OneOf', 'oneOf'], ['Array', 'array'], ['Boolean', 'boolean'], ['S3 Object', 'S3']] as x}
<ToggleButton value={x[1]} label={x[0]} />
{/each}
</ToggleButtonGroup>
</Label>
{/if}
</svelte:fragment>
{#if isFlowInput || isAppInput}
<FlowPropertyEditor
bind:defaultValue={schema.properties[argName].default}
{variableEditor}
{itemPicker}
{lightweightMode}
bind:nullable={schema.properties[argName].nullable}
bind:disabled={schema.properties[argName].disabled}
type={schema.properties[argName].type}
bind:oneOf={schema.properties[argName].oneOf}
bind:format={schema.properties[argName].format}
contentEncoding={schema.properties[argName].contentEncoding}
required={schema.required?.includes(argName) ?? false}
pattern={schema.properties[argName].pattern}
password={schema.properties[argName].password}
propsNames={schema.properties[argName].propsNames}
bind:showExpr={schema.properties[argName].showExpr}
extra={schema.properties[argName]}
customErrorMessage={schema.properties[argName].customErrorMessage}
itemsType={schema.properties[argName].items}
bind:properties={schema.properties[argName].properties}
bind:order={schema.properties[argName].order}
bind:requiredProperty={schema.properties[argName].required}
{displayWebhookWarning}
on:requiredChange={(event) => {
if (event.detail.required) {
schema.required = schema.required ?? []
schema.required.push(argName)
} else {
schema.required = schema.required?.filter((x) => x !== argName)
}
dispatch('change', schema)
}}
on:schemaChange={(e) => {
schema = schema
dispatch('change', schema)
}}
/>
{/if}
</PropertyEditor>
{/if}
{/if}
</div>
{/if}
</div>
{/each}
{:else if !shouldHideNoInputs}
<div class="text-secondary text-sm p-2">No inputs</div>
{/if}
</div>
{:else}
<div class="p-2">
<div class="border rounded h-full">
<SimpleEditor
bind:this={editor}
small
fixedOverflowWidgets={false}
on:change={() => {
try {
schema = JSON.parse(schemaString)
dispatch('change', schema)
error = ''
} catch (err) {
error = err.message
}
}}
bind:code={schemaString}
lang="json"
autoHeight
automaticLayout
/>
</div>
{#if !emptyString(error)}
<div class="text-red-400 text-xs">{error}</div>
{:else}
<div><br /> </div>
{/if}
</div>
{/if}
</Pane>
</Splitpanes>
</div>
{#if !noVariablePicker}
<ItemPicker
bind:this={itemPicker}
pickCallback={(path, _) => {
if (pickForField) {
args[pickForField] = '$var:' + path
}
}}
itemName="Variable"
tooltip="Variables are dynamic values that have a key associated to them and can be retrieved during the execution of a Script or Flow."
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets"
extraField="path"
loadItems={async () =>
(await VariableService.listVariable({ workspace: $workspaceStore ?? '' })).map((x) => ({
name: x.path,
...x
}))}
>
<div slot="submission">
<Button
variant="border"
color="blue"
size="sm"
startIcon={{ icon: Plus }}
on:click={() => variableEditor?.initNew?.()}
>
New Variable
</Button>
</div>
</ItemPicker>
<VariableEditor bind:this={variableEditor} />
{/if}