mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 08:01:26 +00:00
09c8f3b1f3
* feat: responsive modal step panel for the flow editor in sessions On narrow layouts the flow editor's step-details pane opens as a modal (double-click a graph node) instead of a split pane, with a dock/float toggle. Scoped to sessions via allowModalPanel; the full-page editor is unchanged. - FlowEditor: modal/docked modes gated by mount width + allowModalPanel, small header (step-id Badge + subtle dock/close), standing double-click hint, and a per-step hint in the name tooltip - selectionManager: onSelectIntent hook so flow-level panels (settings, input, triggers…) open the modal on single click - PropPickerWrapper: collapse the prop picker until connect and animate it in via AnimatedPane (runs-page pattern), no blue connect ring in modal mode - StepInputGen: drop the TAB/Wand autocompletion button + spinner (feature still works via focus + Tab) - InputTransformForm: decouple the Help dropdown from the AI suggestion - FlowModuleHeader: move 'Save to workspace' into an ellipsis dropdown Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: loop editor rendering and nested splitpanes splitters in the sessions modal - Loop iterator/parallelism: keep the picker split pane (forceExpanded) so the editor fills its box and the picker shows; the collapse-until-connect mode stays for the step inputs - Remove the intrusive AI TAB/Wand autocompletion button from IteratorGen (generation still runs headless via focus + Tab) - Size the iterator connect plug and restyle the loop header/labels/toggles - Scope the global `.splitter-hidden` splitter-hiding rule to direct children so it no longer leaks into nested Splitpanes under the sessions preview Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: redesign flow step advanced settings as a single toggle-first column Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: taller step test pane by default and restyle advanced section titles Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: show flow run-settings params disabled when a setting is toggled off Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: single-column for-loop panel reusing the run-settings accordion Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: single-column while-loop panel reusing the run-settings accordion Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: single-column branch panels reusing the run-settings accordion Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: auto-open modal panel when creating an AI agent tool Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: redesign branch panels with card layout and shared predicate editor Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: remove per-setting status badges from flow map nodes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: sync package-lock after windmill-utils-internal bump Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style: polish prop-picker plug button and branch panel layouts * fix: persist skip-if-stopped toggles in early stop settings * fix: open the step panel modal on demand and cap its width * fix: restore graph step setting badges, strip panel header chips instead * feat: docked panel header with detach action and open-details step menu * feat: width-based panel mode on every surface with inline detach action * refactor: single source for flow step settings and their defaults * docs: pin flow editor vocabulary in CONTEXT.md * fix: open the trigger panel on double click or a specific trigger * fix: keep module pickers inside their pane and dismissable * fix: drop the misleading chevron on the MCP tool entry * fix: resolve flow approvals against the job's workspace, not the nav one * refactor: derive the approval workspace from the job, not from callers * fix: restore S3 snippets and gate params while their setting is off * fix: restore branch mock controls and address review findings * chore: drop stray debug log from the flow map item * feat: pinned output section for loop and branch panels * fix: open the panel for deliberate navigation from the flow header * perf: mount branch predicate editors on demand * fix: skip predicate picker previews the previous step's result * fix: flow-level graph nodes open their panel on a single click * fix: open the step panel for AI chat selections, not for undo * chore: drop dead console.log and duplicated modalPanel doc * fix: re-sync expression editors and scope error-handler settings * fix: match the failure module exactly and ignore unselectable nodes * fix: keep concurrency editable, honour module cache_ttl, tighten panel ids * fix: open panel from indirect selections, use presence for value-driven toggles * fix: don't open settings on error-handler delete, flush editors on unmount * fix: guard editor destroy flush, keep retry kind reachable * refactor: name the run settings panel after the domain vocabulary * fix: only write editor flushes to the step they belong to * fix: bind step panels by id so a delete can't retarget editor writes * fix: don't let the trigger picker's escape close the drawer beneath it * docs: condense two comments to the constraint they record * fix: arbitrate escape through the overlay stack instead of deferring to it * fix: key nested step blocks by identity so anchored bindings can't go stale * fix: untrack the overlay-stack push and drop the frozen branch binding * chore: state the escape rationale once, key branch lists, format * fix: let the topmost overlay own escape instead of the graph * fix: keep the dynamic-input help box out of static template fields * fix: restore the graph connect on the for-loop iterator * fix: end connect mode with the modal and keep it to docked panels * fix: never enter graph connect mode from the modal panel * fix: reveal inserted steps, restore editor pane size, unleak the drawer stack * fix: keep the enable-AI popover reachable in session panes * feat: add the connect policy and its single armed slot * refactor: one picker for every expression input * refactor: route every connect through one armed slot * fix: give every connect button the same footprint * fix: keep the connect ring from showing through the button * fix: keep flow card actions right-aligned beside the detach button * fix: give the connect ring an opaque ground to mask against * feat: dock the panel back without reopening it * feat: dock the panel from the graph control bar * style: round the graph control bar and size its glyphs * style: customize the graph controls through their supported api * style: build the graph control bar from lucide icons * fix: use the graph's tooltip component in the zoom controls * style: pad the graph controls and enlarge their glyphs * style: pad the graph controls and put dock at the bar's end * refactor: give settings rows the same popover picker as other expressions * fix: pass the wrapper's pickable properties to nested inputs * refactor: stack step settings and render every expression through the step input form * feat: split loop panels into tabs and rework the approval form * feat: anchor drawers to their host pane and give them a size floor * fix: mark the loop iterator expression as required * refactor: badge ee-only toggles instead of a warning line * fix: flag an empty loop iterator expression as an error * refactor: pick the early-stop flow status from one toggle group * fix: keep parallel loops uncapped unless a limit is opted into * fix: scope the overlay stack to its host and disarm connect on dismissal * fix: anchor the trigger picker to its host pane * feat: move diff into the menu when the top bar is narrow * fix: gate the result logs toggle to the graph popover * feat: raise the modal-panel breakpoint to 1280 * fix: anchor flow editor popovers and fullscreen to their host pane * fix: anchor overlays to their host pane and mute them when hidden * fix: portal hosted modals and menus into the pane they anchor to * fix: keep non-listening dialogs off the overlay stack * fix: drop the topmost gate from confirmation dialogs * fix: silence overlays in a collapsed preview panel * feat: rework the branch panels with tabs, reordering and add/delete * refactor: fold the detached-panel chrome into the card header * fix: give every flow panel a titled card header * fix: stop the step panel oscillating on an auto-height editor * feat: consolidate script panel actions and restore branch predicate AI * fix: restore the logs toggle on the flow result popover * fix: collapse the idle property picker in modal step panels * fix: stop the docked pane scrolling alongside its panel * fix: space the last settings row off the panel bottom * revert: always show the property picker pane in step panels * chore: keep the inline script AI button identical to main * fix: ask for AI input suggestions on click, not on hover * fix: keep graph connects armed and remount the parallelism input * style: reveal the predicate AI button on row hover * style: give branch cards a handle and delete column * refactor: arbitrate flow overlay escape through Disposable * fix: give the popover picker its results and re-narrow the EE badge * docs: correct loopSubset and guard the modal width measurement * fix: insert picked properties at the cursor in expression inputs * fix: give the expanded-subflow panel the shared header chrome * style: rename the suspend setting to Suspend until approval/resume * feat: open a step's modal when clicking the step already selected * feat: add an auto/attached/detached toggle for the step panel * refactor: pick the step panel's placement from one named menu * refactor: keep the panel-mode module's exports to what is consumed * feat: show each configured setting's value on its badge * fix: carry the suspend rename into the step settings registry * docs: name both gestures in the step explore hint * test: pin where the step panel goes for a given width and preference --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1561 lines
46 KiB
Svelte
1561 lines
46 KiB
Svelte
<script lang="ts">
|
|
import { preventDefault, stopPropagation, createBubbler } from 'svelte/legacy'
|
|
|
|
const bubble = createBubbler()
|
|
import type { EnumType, SchemaProperty } from '$lib/common'
|
|
import {
|
|
setInputCat as computeInputCat,
|
|
debounce,
|
|
emptySchema,
|
|
emptyString,
|
|
getSchemaFromProperties,
|
|
type DynamicInput as DynamicInputTypes
|
|
} from '$lib/utils'
|
|
import { DollarSign, Plus, X, Check, Loader2, ExternalLink } from 'lucide-svelte'
|
|
import { createEventDispatcher, onDestroy, onMount, tick, untrack } from 'svelte'
|
|
import { fade } from 'svelte/transition'
|
|
import { Alert, Button, SecondsInput } from './common'
|
|
import FieldHeader from './FieldHeader.svelte'
|
|
import type ItemPicker from './ItemPicker.svelte'
|
|
import ObjectResourceInput from './ObjectResourceInput.svelte'
|
|
import Range from './Range.svelte'
|
|
import ResourcePicker from './ResourcePicker.svelte'
|
|
import Toggle from './Toggle.svelte'
|
|
import type VariableEditor from './VariableEditor.svelte'
|
|
import { twMerge } from 'tailwind-merge'
|
|
import ArgEnum from './ArgEnum.svelte'
|
|
import DateTimeInput from './DateTimeInput.svelte'
|
|
import DateInput from './DateInput.svelte'
|
|
import CurrencyInput from './apps/components/inputs/currency/CurrencyInput.svelte'
|
|
import PasswordArgInput from './PasswordArgInput.svelte'
|
|
import Password from './Password.svelte'
|
|
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
|
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
|
import SchemaFormDnd from './schema/SchemaFormDND.svelte'
|
|
import SchemaForm from './SchemaForm.svelte'
|
|
import { deepEqual } from 'fast-equals'
|
|
import DynamicInput from './DynamicInput.svelte'
|
|
import type { SchemaDiff } from '$lib/components/schema/schemaUtils.svelte'
|
|
import type { ComponentCustomCSS } from './apps/types'
|
|
import MultiSelect from './select/MultiSelect.svelte'
|
|
import { safeSelectItems } from './select/utils.svelte'
|
|
import S3ArgInput from './common/fileUpload/S3ArgInput.svelte'
|
|
import { base } from '$lib/base'
|
|
import { workspaceStore } from '$lib/stores'
|
|
import { getJsonSchemaFromResource } from './schema/jsonSchemaResource.svelte'
|
|
import AIProviderPicker from './AIProviderPicker.svelte'
|
|
import TextInput from './text_input/TextInput.svelte'
|
|
import FileInput from './common/fileInput/FileInput.svelte'
|
|
import { randomUUID } from '$lib/utils/uuid'
|
|
|
|
interface Props {
|
|
label?: string
|
|
value: any
|
|
defaultValue?: any
|
|
description?: string
|
|
format?: string
|
|
contentEncoding?: 'base64' | 'binary' | undefined
|
|
type?: string | undefined
|
|
oneOf?: SchemaProperty[] | undefined
|
|
required?: boolean
|
|
pattern?: undefined | string
|
|
valid?: boolean
|
|
enum_?: EnumType
|
|
disabled?: boolean
|
|
itemsType?:
|
|
| {
|
|
type?: 'string' | 'number' | 'bytes' | 'object' | 'resource'
|
|
contentEncoding?: 'base64'
|
|
enum?: string[]
|
|
multiselect?: string[]
|
|
resourceType?: string
|
|
properties?: { [name: string]: SchemaProperty }
|
|
}
|
|
| undefined
|
|
lightHeaderFont?: boolean
|
|
displayHeader?: boolean
|
|
properties?: { [name: string]: SchemaProperty } | undefined
|
|
nestedRequired?: string[] | undefined
|
|
autofocus?: boolean | null
|
|
compact?: boolean
|
|
password?: boolean
|
|
pickForField?: string | undefined
|
|
variableEditor?: VariableEditor | undefined
|
|
itemPicker?: ItemPicker | undefined
|
|
noMargin?: boolean
|
|
extra?: Record<string, any>
|
|
minW?: boolean
|
|
prettifyHeader?: boolean
|
|
resourceTypes: string[] | undefined
|
|
disablePortal?: boolean
|
|
showSchemaExplorer?: boolean
|
|
simpleTooltip?: string | undefined
|
|
customErrorMessage?: string | undefined
|
|
onlyMaskPassword?: boolean
|
|
nullable?: boolean
|
|
title?: string | undefined
|
|
placeholder?: string | undefined
|
|
order?: string[] | undefined
|
|
editor?: any | undefined
|
|
orderEditable?: boolean
|
|
shouldDispatchChanges?: boolean
|
|
noDefaultOnSelectFirst?: boolean
|
|
helperScript?: DynamicInputTypes.HelperScript
|
|
otherArgs?: Record<string, any>
|
|
lightHeader?: boolean
|
|
diffStatus?: SchemaDiff | undefined
|
|
hideNested?: boolean
|
|
nestedParent?: { label: string; nestedParent: any | undefined } | undefined
|
|
nestedClasses?: string
|
|
displayType?: boolean
|
|
css?: ComponentCustomCSS<'schemaformcomponent'> | undefined
|
|
appPath?: string | undefined
|
|
computeS3ForceViewerPolicies?:
|
|
| (() =>
|
|
| {
|
|
allowed_resources: string[]
|
|
allow_user_resources: boolean
|
|
allow_workspace_resource: boolean
|
|
file_key_regex: string
|
|
}
|
|
| undefined)
|
|
| undefined
|
|
workspace?: string | undefined
|
|
s3StorageConfigured?: boolean
|
|
chatInputEnabled?: boolean
|
|
actions?: import('svelte').Snippet
|
|
innerBottomSnippet?: import('svelte').Snippet
|
|
fieldHeaderActions?: import('svelte').Snippet
|
|
}
|
|
|
|
let {
|
|
label = '',
|
|
value = $bindable(),
|
|
defaultValue = $bindable(undefined),
|
|
description = $bindable(undefined),
|
|
format = $bindable(undefined),
|
|
contentEncoding = undefined,
|
|
type = undefined,
|
|
oneOf = $bindable(undefined),
|
|
required = false,
|
|
pattern = $bindable(undefined),
|
|
valid = $bindable(undefined), // Note : this should not exist, valid and error should be one coherent state
|
|
enum_ = $bindable(undefined),
|
|
disabled = false,
|
|
itemsType = $bindable(undefined),
|
|
displayHeader = true,
|
|
properties = $bindable(undefined),
|
|
nestedRequired = undefined,
|
|
autofocus = null,
|
|
compact = false,
|
|
password = false,
|
|
pickForField = $bindable(undefined),
|
|
variableEditor = undefined,
|
|
itemPicker = undefined,
|
|
noMargin = false,
|
|
extra = {},
|
|
minW = true,
|
|
prettifyHeader = false,
|
|
resourceTypes,
|
|
disablePortal = false,
|
|
showSchemaExplorer = false,
|
|
simpleTooltip = undefined,
|
|
customErrorMessage = undefined,
|
|
onlyMaskPassword = false,
|
|
nullable = false,
|
|
title = $bindable(undefined),
|
|
placeholder = $bindable(undefined),
|
|
order = $bindable(undefined),
|
|
editor = $bindable(undefined),
|
|
orderEditable = false,
|
|
shouldDispatchChanges = false,
|
|
noDefaultOnSelectFirst = false,
|
|
helperScript = undefined,
|
|
otherArgs = {},
|
|
lightHeader = false,
|
|
diffStatus = undefined,
|
|
hideNested = false,
|
|
nestedParent = undefined,
|
|
nestedClasses = '',
|
|
displayType = true,
|
|
css = undefined,
|
|
appPath = undefined,
|
|
computeS3ForceViewerPolicies = undefined,
|
|
workspace = undefined,
|
|
s3StorageConfigured = true,
|
|
chatInputEnabled = false,
|
|
actions,
|
|
innerBottomSnippet,
|
|
fieldHeaderActions,
|
|
lightHeaderFont = false
|
|
}: Props = $props()
|
|
|
|
$effect(() => {
|
|
if (description == undefined) {
|
|
description = ''
|
|
}
|
|
if (format == undefined) {
|
|
format = ''
|
|
}
|
|
if (valid == undefined) {
|
|
valid = true
|
|
}
|
|
})
|
|
|
|
let oneOfSelected: string | undefined = $state(undefined)
|
|
let tagKey = $derived(
|
|
oneOf?.find((o) => Object.keys(o.properties ?? {})?.includes('kind')) ? 'kind' : 'label'
|
|
)
|
|
async function updateOneOfSelected(oneOf: SchemaProperty[] | undefined) {
|
|
if (
|
|
oneOf &&
|
|
oneOf.length >= 2 &&
|
|
(!oneOfSelected || !oneOf.some((o) => o.title === oneOfSelected) || !value)
|
|
) {
|
|
if (value && value[tagKey] && oneOf.some((o) => o.title === value[tagKey])) {
|
|
const existingValue = JSON.parse(JSON.stringify(value))
|
|
oneOfSelected = value[tagKey]
|
|
await tick()
|
|
value = existingValue
|
|
} else {
|
|
const variantTitle = oneOf[0]['title']
|
|
oneOfSelected = variantTitle
|
|
value = { ...(typeof value === 'object' ? (value ?? {}) : {}), [tagKey]: variantTitle }
|
|
}
|
|
}
|
|
}
|
|
|
|
function onOneOfChange() {
|
|
const label = value?.['label']
|
|
const kind = value?.['kind']
|
|
if (label && oneOf && oneOf.some((o) => o.title == label) && oneOfSelected != label) {
|
|
oneOfSelected = label
|
|
} else if (kind && oneOf && oneOf.some((o) => o.title == kind) && oneOfSelected != kind) {
|
|
oneOfSelected = kind
|
|
}
|
|
}
|
|
|
|
const dispatch = createEventDispatcher()
|
|
|
|
let ignoreValueUndefined = $state(false)
|
|
let error: string = $state('')
|
|
let isListJson = $state(false)
|
|
let hasIsListJsonChanged = $state(false)
|
|
|
|
let el: HTMLTextAreaElement | undefined = $state(undefined)
|
|
|
|
let rawValue: string | undefined = $state(undefined)
|
|
|
|
let lastValue: any = null
|
|
|
|
function computeDefaultValue(inputCat?: string, defaultValue?: any, nnullable?: boolean) {
|
|
let nvalue: any = null
|
|
if (label == 'toString' && typeof value == 'function') {
|
|
nvalue = undefined
|
|
}
|
|
if ((value == undefined || value == null) && !ignoreValueUndefined) {
|
|
nvalue = structuredClone($state.snapshot(defaultValue))
|
|
if (defaultValue === undefined || defaultValue === null) {
|
|
if (inputCat === 'string') {
|
|
nvalue = nullable
|
|
? null
|
|
: format === 'uuid' && extra?.['x-auto-generate']
|
|
? randomUUID()
|
|
: ''
|
|
} else if (inputCat == 'enum' && required) {
|
|
let firstV = enum_?.[0]
|
|
if (typeof firstV === 'string') {
|
|
nvalue = firstV
|
|
} else if (firstV && typeof firstV === 'object') {
|
|
nvalue = firstV.value
|
|
}
|
|
} else if (inputCat == 'boolean') {
|
|
nvalue = false
|
|
} else if (inputCat == 'list') {
|
|
nvalue = nullable ? null : []
|
|
}
|
|
} else if (inputCat === 'object') {
|
|
evalValueToRaw()
|
|
}
|
|
}
|
|
|
|
if (nnullable && type === 'string' && value === '' && nvalue == null) {
|
|
value = null
|
|
} else if (nvalue != null && !deepEqual(nvalue, value)) {
|
|
value = nvalue
|
|
lastValue = value
|
|
}
|
|
}
|
|
|
|
// By setting isListJson to true, we can render inputs even if the value is not an array of the correct type
|
|
// This avoids the issue of the input being rendered as a string with value: [object Object], or as a number with value: NaN
|
|
function checkArrayValueType() {
|
|
try {
|
|
if (Array.isArray(value) && value.length > 0) {
|
|
let firstItem = value?.[0]
|
|
const type = itemsType?.type
|
|
|
|
switch (type) {
|
|
case 'string':
|
|
if (typeof firstItem !== 'string') {
|
|
isListJson = true
|
|
}
|
|
break
|
|
case 'number':
|
|
if (typeof firstItem !== 'number') {
|
|
isListJson = true
|
|
}
|
|
break
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error(e)
|
|
}
|
|
|
|
lastValue = value
|
|
}
|
|
|
|
let oldDefaultValue = structuredClone($state.snapshot(defaultValue))
|
|
function handleDefaultValueChange() {
|
|
if (
|
|
deepEqual(value, oldDefaultValue) &&
|
|
!deepEqual(value, defaultValue) &&
|
|
!deepEqual(defaultValue, oldDefaultValue)
|
|
) {
|
|
value = defaultValue
|
|
}
|
|
oldDefaultValue = structuredClone($state.snapshot(defaultValue))
|
|
}
|
|
|
|
function isObjectCat(inputCat?: string) {
|
|
return (
|
|
inputCat === 'object' || inputCat === 'resource-object' || (inputCat == 'list' && !isListJson)
|
|
)
|
|
}
|
|
|
|
function isRawStringEditor(inputCat?: string) {
|
|
return inputCat == 'sql' || inputCat == 'yaml'
|
|
}
|
|
|
|
function evalValueToRaw() {
|
|
if (setCodeDisabled) {
|
|
return
|
|
}
|
|
const newRawValue =
|
|
value == undefined || value == null
|
|
? ''
|
|
: isObjectCat(inputCat) || (inputCat == 'list' && isListJson)
|
|
? JSON.stringify(value, null, 2)
|
|
: isRawStringEditor(inputCat)
|
|
? typeof value == 'string'
|
|
? value
|
|
: JSON.stringify(value, null, 2)
|
|
: undefined
|
|
|
|
if (newRawValue != rawValue) {
|
|
rawValue = newRawValue
|
|
rawValue != undefined && editor?.getCode() != rawValue && editor?.setCode(rawValue)
|
|
}
|
|
}
|
|
|
|
let setCodeDisabled = false
|
|
|
|
let timeout: number | undefined = undefined
|
|
function setNewValueFromCode(nvalue: any) {
|
|
if (!deepEqual(nvalue, value)) {
|
|
value = nvalue
|
|
timeout && clearTimeout(timeout)
|
|
setCodeDisabled = true
|
|
timeout = setTimeout(() => {
|
|
setCodeDisabled = false
|
|
}, 1000)
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
evalValueToRaw()
|
|
})
|
|
|
|
function fileChangedInner(file: File | undefined, cb: (v: string | undefined) => void) {
|
|
if (!file) {
|
|
cb(undefined)
|
|
return
|
|
}
|
|
let reader = new FileReader()
|
|
reader.onload = (e: any) => {
|
|
cb(e.target.result.split('base64,')[1])
|
|
}
|
|
reader.readAsDataURL(file)
|
|
}
|
|
|
|
export function focus() {
|
|
el?.focus()
|
|
if (el) {
|
|
el.style.height = '5px'
|
|
el.style.height = el.scrollHeight + 50 + 'px'
|
|
}
|
|
}
|
|
const EMAIL_PATTERN = '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$'
|
|
const IPV4_PATTERN =
|
|
'^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$'
|
|
const UUID_PATTERN = '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
const IPV6_PATTERN =
|
|
'^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$'
|
|
|
|
function validateInput(pattern: string | undefined, v: any, required: boolean): void {
|
|
if (nullable && emptyString(v)) {
|
|
error = ''
|
|
valid && (valid = true)
|
|
} else if (
|
|
typeof v === 'string' &&
|
|
(v.startsWith('$var:') || v.startsWith('$res:') || v.startsWith('$jsonvar:'))
|
|
) {
|
|
// $var/$res/$jsonvar are placeholders resolved at runtime; the literal
|
|
// string won't match format constraints (email/ipv4/uuid/custom pattern),
|
|
// so format-checking it produces a false-positive "invalid format" error.
|
|
error = ''
|
|
!valid && (valid = true)
|
|
} else if (required && (v == undefined || v == null || v === '') && inputCat != 'object') {
|
|
error = 'Required'
|
|
valid && (valid = false)
|
|
} else if (
|
|
required &&
|
|
inputCat == 'list' &&
|
|
extra?.['nonEmpty'] == true &&
|
|
Array.isArray(v) &&
|
|
v.length === 0
|
|
) {
|
|
error = 'Required'
|
|
valid && (valid = false)
|
|
} else if (inputCat == 'list' && !Array.isArray(v)) {
|
|
error = 'Expected an array, got ' + typeof v + ' instead'
|
|
valid && (valid = false)
|
|
} else {
|
|
if (inputCat == 'number' && typeof v === 'number') {
|
|
let min = extra['min']
|
|
let max = extra['max']
|
|
if (min != undefined && typeof min == 'number' && v < min) {
|
|
error = `Should be greater than or equal to ${min}`
|
|
valid && (valid = false)
|
|
} else if (max != undefined && typeof max == 'number' && v > max) {
|
|
error = `Should be less than or equal to ${max}`
|
|
valid && (valid = false)
|
|
} else {
|
|
error = ''
|
|
!valid && (valid = true)
|
|
}
|
|
} else if (type == 'string' && format == 'email' && !testRegex(EMAIL_PATTERN, v)) {
|
|
error = 'invalid email address'
|
|
} else if (type == 'string' && format == 'ipv4' && !testRegex(IPV4_PATTERN, v)) {
|
|
error = 'invalid IPv4 address'
|
|
} else if (type == 'string' && format == 'ipv6' && !testRegex(IPV6_PATTERN, v)) {
|
|
error = 'invalid IPv6 address'
|
|
} else if (type == 'string' && format == 'uuid' && !testRegex(UUID_PATTERN, v)) {
|
|
error = 'invalid UUID'
|
|
} else if (pattern && !testRegex(pattern, v)) {
|
|
if (!emptyString(customErrorMessage)) {
|
|
error = customErrorMessage ?? ''
|
|
} else if (format == 'email') {
|
|
error = 'invalid email address'
|
|
} else {
|
|
error = `Should match ${pattern}`
|
|
}
|
|
valid && (valid = false)
|
|
} else {
|
|
error = ''
|
|
!valid && (valid = true)
|
|
}
|
|
}
|
|
}
|
|
|
|
function testRegex(pattern: string, value: any): boolean {
|
|
try {
|
|
const regex = new RegExp(pattern)
|
|
return regex.test(value)
|
|
} catch (err) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if (
|
|
(e.ctrlKey || e.metaKey) &&
|
|
(e.key == 'Enter' || e.key == 'c' || e.key == 'v' || e.key == 'x')
|
|
) {
|
|
if (e.key == 'Enter') {
|
|
dispatch('keydownCmdEnter')
|
|
}
|
|
return
|
|
}
|
|
e.stopPropagation()
|
|
}
|
|
|
|
let redraw = $state(0)
|
|
let itemsLimit = $state(50)
|
|
|
|
let oldValue = value
|
|
|
|
function compareValues(value) {
|
|
if (!deepEqual(oldValue, value)) {
|
|
oldValue = value
|
|
dispatch('change')
|
|
}
|
|
}
|
|
|
|
let { debounced, clearDebounce } = debounce(() => compareValues(value), 50)
|
|
let inputCat = $derived(computeInputCat(type, format, itemsType?.type, enum_, contentEncoding))
|
|
let isNonStringSecret = $derived((password || extra?.['password'] == true) && type === 'object')
|
|
|
|
let displayJsonToggleHeader = $derived(
|
|
displayHeader &&
|
|
inputCat === 'list' &&
|
|
!(itemsType?.resourceType === 's3_object' || itemsType?.resourceType === 's3object')
|
|
)
|
|
$effect(() => {
|
|
oneOf && untrack(() => updateOneOfSelected(oneOf))
|
|
})
|
|
$effect(() => {
|
|
oneOf && value && untrack(() => onOneOfChange())
|
|
})
|
|
|
|
$effect.pre(() => {
|
|
value
|
|
let args = [inputCat, defaultValue, nullable]
|
|
|
|
untrack(() => {
|
|
if (deepEqual(value, lastValue)) return
|
|
computeDefaultValue(...args)
|
|
})
|
|
})
|
|
$effect.pre(() => {
|
|
!isListJson &&
|
|
inputCat === 'list' &&
|
|
enum_ == undefined &&
|
|
value != lastValue &&
|
|
itemsType?.type &&
|
|
!hasIsListJsonChanged &&
|
|
untrack(() => checkArrayValueType())
|
|
})
|
|
$effect.pre(() => {
|
|
defaultValue != undefined && untrack(() => handleDefaultValueChange())
|
|
})
|
|
$effect.pre(() => {
|
|
;(inputCat &&
|
|
(isObjectCat(inputCat) || isRawStringEditor(inputCat)) &&
|
|
!oneOf &&
|
|
untrack(() => evalValueToRaw())) ||
|
|
value
|
|
})
|
|
|
|
$effect(() => {
|
|
extra?.['nonEmpty']
|
|
let args = [pattern, value, required] as const
|
|
untrack(() => validateInput(...args))
|
|
})
|
|
$effect(() => {
|
|
shouldDispatchChanges && debounced(value)
|
|
})
|
|
|
|
onDestroy(() => {
|
|
clearDebounce()
|
|
})
|
|
</script>
|
|
|
|
{#snippet variableInput()}
|
|
{#if variableEditor}
|
|
<div class="text-2xs text-hint">
|
|
{#if value && typeof value == 'string' && value?.startsWith('$var:')}
|
|
Linked to variable <button
|
|
class="text-accent underline font-normal"
|
|
onclick={() => variableEditor?.editVariable?.(value.slice(5))}>{value.slice(5)}</button
|
|
>
|
|
{:else if value && typeof value == 'string' && value?.startsWith('$jsonvar:')}
|
|
Linked to variable <button
|
|
class="text-accent underline font-normal"
|
|
onclick={() => variableEditor?.editVariable?.(value.slice('$jsonvar:'.length))}
|
|
>{value.slice('$jsonvar:'.length)}</button
|
|
>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
{#snippet resourceInput()}
|
|
{#if variableEditor}
|
|
<div class="text-sm text-primary">
|
|
{#if value && typeof value == 'string' && value?.startsWith('$res:')}
|
|
Linked to resource <a
|
|
target="_blank"
|
|
href="{base}/resources#/resource/{value.slice(5)}"
|
|
class="text-blue-500 underline"
|
|
>{value.slice(5)} <span class="inline-block -mb-0.5"><ExternalLink size={14} /></span></a
|
|
>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
<!-- svelte-ignore a11y_autofocus -->
|
|
<div
|
|
class={twMerge(
|
|
'flex flex-col gap-1 w-full rounded-md relative group',
|
|
minW ? 'min-w-[250px]' : '',
|
|
diffStatus?.diff ? 'px-2' : '',
|
|
diffStatus?.diff == 'added'
|
|
? 'bg-green-300 dark:bg-green-800'
|
|
: diffStatus?.diff === 'removed'
|
|
? 'bg-red-300 dark:bg-red-800'
|
|
: diffStatus?.diff === 'same'
|
|
? 'bg-surface'
|
|
: diffStatus?.diff === 'modified' || typeof diffStatus?.diff === 'object'
|
|
? 'border-2 border-green-500 bg-surface'
|
|
: ''
|
|
)}
|
|
data-schema-picker
|
|
>
|
|
{#if diffStatus && typeof diffStatus === 'object' && diffStatus.diff !== 'same'}
|
|
<div
|
|
class="absolute top-0 right-2 rounded-md rounded-t-none flex flex-row overflow-hidden bg-surface"
|
|
>
|
|
<button
|
|
class="p-1 bg-green-500 text-white hover:bg-green-600"
|
|
onclick={stopPropagation(
|
|
preventDefault(() => {
|
|
dispatch('acceptChange', { label, nestedParent })
|
|
})
|
|
)}
|
|
>
|
|
<Check size={14} />
|
|
</button>
|
|
<button
|
|
class="p-1 hover:bg-red-500 hover:text-white"
|
|
onclick={stopPropagation(
|
|
preventDefault(() => {
|
|
dispatch('rejectChange', { label, nestedParent })
|
|
})
|
|
)}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{#if displayHeader}
|
|
<div class="flex items-end">
|
|
<FieldHeader
|
|
prettify={prettifyHeader}
|
|
label={title && !emptyString(title) ? title : label}
|
|
{disabled}
|
|
{required}
|
|
{type}
|
|
{contentEncoding}
|
|
{format}
|
|
{simpleTooltip}
|
|
{lightHeader}
|
|
{displayType}
|
|
labelClass={twMerge(lightHeaderFont ? '!font-normal text-primary' : '', css?.label?.class)}
|
|
/>
|
|
<div class="ml-auto flex gap-2">
|
|
{#if displayJsonToggleHeader}
|
|
<Toggle
|
|
on:change={(e) => {
|
|
// Once the user has changed the input type, we should not change it back automatically
|
|
if (!hasIsListJsonChanged) {
|
|
hasIsListJsonChanged = true
|
|
}
|
|
|
|
evalValueToRaw()
|
|
isListJson = !isListJson
|
|
}}
|
|
checked={isListJson}
|
|
textClass="text-secondary"
|
|
size="xs"
|
|
options={{ left: 'json' }}
|
|
/>
|
|
{/if}
|
|
{@render fieldHeaderActions?.()}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if description}
|
|
<div class={twMerge('text-xs text-secondary', css?.description?.class)}>
|
|
<pre class="font-main whitespace-normal">{description}</pre>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex space-x-1">
|
|
{#if inputCat == 'number'}
|
|
{#if extra['seconds'] !== undefined}
|
|
<div class="w-full">
|
|
<SecondsInput
|
|
bind:seconds={value}
|
|
onfocus={bubble('focus')}
|
|
{defaultValue}
|
|
clearable={extra['clearable'] !== false}
|
|
/>
|
|
</div>
|
|
{:else if extra['min'] != undefined && extra['max'] != undefined}
|
|
<Range bind:value min={extra['min']} max={extra['max']} {defaultValue} />
|
|
{:else if extra?.currency}
|
|
<CurrencyInput
|
|
inputClasses={{
|
|
formatted: 'px-2 w-full py-1.5 text-black dark:text-white',
|
|
wrapper: 'w-full windmillapp',
|
|
formattedZero: 'text-black dark:text-white'
|
|
}}
|
|
noColor
|
|
bind:value
|
|
currency={extra?.currency}
|
|
locale={extra?.currencyLocale ?? 'en-US'}
|
|
{disabled}
|
|
/>
|
|
{:else}
|
|
<div class="relative w-full">
|
|
<TextInput
|
|
inputProps={{
|
|
autofocus,
|
|
onfocus: bubble('focus'),
|
|
onblur: bubble('blur'),
|
|
disabled,
|
|
type: 'number',
|
|
onkeydown: () => (ignoreValueUndefined = true),
|
|
placeholder: placeholder ?? defaultValue ?? '',
|
|
min: extra['min'],
|
|
max: extra['max'],
|
|
step: extra['step']
|
|
}}
|
|
{error}
|
|
bind:value
|
|
/>
|
|
<!-- <input
|
|
{autofocus}
|
|
onfocus={bubble('focus')}
|
|
onblur={bubble('blur')}
|
|
{disabled}
|
|
type="number"
|
|
onkeydown={() => {
|
|
ignoreValueUndefined = true
|
|
}}
|
|
class={valid
|
|
? ''
|
|
: 'border border-red-700 border-opacity-30 focus:border-red-700 focus:border-opacity-30 bg-red-100'}
|
|
placeholder={placeholder ?? defaultValue ?? ''}
|
|
bind:value
|
|
min={extra['min']}
|
|
max={extra['max']}
|
|
/> -->
|
|
</div>
|
|
{/if}
|
|
{:else if inputCat == 'boolean'}
|
|
<div class="w-full">
|
|
<Toggle
|
|
on:pointerdown={(e) => {
|
|
e?.stopPropagation()
|
|
}}
|
|
{disabled}
|
|
class={valid
|
|
? ''
|
|
: 'border border-red-700 border-opacity-30 focus:border-red-700 focus:border-opacity-30 bg-red-100'}
|
|
bind:checked={value}
|
|
/>
|
|
{#if type == 'boolean' && value == undefined}
|
|
<span> Not set</span>
|
|
{/if}
|
|
</div>
|
|
{:else if (inputCat == 'resource-object' && format && format.split('-').length > 1 && format
|
|
.replace('resource-', '')
|
|
.replace('_', '')
|
|
.toLowerCase() == 's3object') || (inputCat == 'list' && (itemsType?.resourceType === 's3_object' || itemsType?.resourceType === 's3object'))}
|
|
<S3ArgInput
|
|
multiple={inputCat == 'list'}
|
|
bind:value
|
|
{defaultValue}
|
|
{setNewValueFromCode}
|
|
{workspace}
|
|
onFocus={() => dispatch('focus')}
|
|
onBlur={() => dispatch('blur')}
|
|
bind:editor
|
|
{appPath}
|
|
{computeS3ForceViewerPolicies}
|
|
bottom={innerBottomSnippet}
|
|
hideCatalogPicker={extra?.['hideCatalogPicker'] ?? false}
|
|
hideRawInput={extra?.['hideRawInput'] ?? false}
|
|
/>
|
|
{:else if inputCat == 'object' && format == 'json-schema'}
|
|
{#await import('$lib/components/EditableSchemaForm.svelte')}
|
|
<Loader2 class="animate-spin" />
|
|
{:then Module}
|
|
<Module.default
|
|
bind:schema={
|
|
() =>
|
|
value && typeof value === 'object' && !Array.isArray(value) ? value : emptySchema(),
|
|
(v) => {
|
|
value = v
|
|
}
|
|
}
|
|
isFlowInput
|
|
editTab="inputEditor"
|
|
noPreview
|
|
addPropertyInEditorTab
|
|
on:delete={(e) => {
|
|
// Handle property deletion
|
|
if (value && value.properties && value.properties[e.detail]) {
|
|
delete value.properties[e.detail]
|
|
// Also remove from order array if it exists
|
|
if (value.order) {
|
|
value.order = value.order.filter((key) => key !== e.detail)
|
|
}
|
|
// Update the value to trigger reactivity
|
|
value = { ...value }
|
|
dispatch('change')
|
|
}
|
|
}}
|
|
/>
|
|
{/await}
|
|
{:else if inputCat == 'object' && format?.startsWith('jsonschema-')}
|
|
{#await getJsonSchemaFromResource(format.substring('jsonschema-'.length), workspace ?? $workspaceStore ?? '')}
|
|
<Loader2 class="animate-spin" />
|
|
{:then schema}
|
|
{#if !schema || !schema.properties}
|
|
<div>
|
|
<div class="w-full">
|
|
{#await import('$lib/components/JsonEditor.svelte')}
|
|
<Loader2 class="animate-spin" />
|
|
{:then Module}
|
|
<Module.default code={JSON.stringify(value, null, 2)} bind:value />
|
|
{/await}
|
|
</div>
|
|
<div class="text-red-500 text-2xs">
|
|
Error loading json schema resource {format.substring('jsonschema-'.length)}, please
|
|
check if the resource exists and is a valid json schema.
|
|
<a
|
|
href="https://windmill.dev/docs/core_concepts/resources_and_types#json-schema-resources"
|
|
target="_blank"
|
|
class="text-blue-500 hover:text-blue-700 underline">See documentation</a
|
|
>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="px-4 pt-4 border rounded-md w-full">
|
|
<SchemaForm
|
|
lightHeaderFont
|
|
{onlyMaskPassword}
|
|
{disablePortal}
|
|
{disabled}
|
|
{prettifyHeader}
|
|
{schema}
|
|
bind:args={value}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{/await}
|
|
{:else if inputCat == 'list' && !isListJson}
|
|
<div class="w-full flex gap-4">
|
|
<div class="w-full">
|
|
{#if Array.isArray(itemsType?.multiselect) && Array.isArray(value)}
|
|
<div class="items-start">
|
|
<MultiSelect
|
|
{disabled}
|
|
bind:value
|
|
items={safeSelectItems(itemsType?.multiselect)}
|
|
onOpen={() => dispatch('focus')}
|
|
reorderable
|
|
/>
|
|
</div>
|
|
{:else if enum_ && (Array.isArray(value) || value == undefined)}
|
|
<div class="items-start">
|
|
<MultiSelect
|
|
{disabled}
|
|
bind:value
|
|
items={safeSelectItems(enum_)}
|
|
onOpen={() => dispatch('focus')}
|
|
reorderable
|
|
/>
|
|
</div>
|
|
{:else if itemsType?.enum != undefined && Array.isArray(itemsType?.enum) && (Array.isArray(value) || value == undefined)}
|
|
<div class="items-start">
|
|
<MultiSelect
|
|
{disabled}
|
|
bind:value
|
|
items={safeSelectItems(itemsType?.enum)}
|
|
onOpen={() => dispatch('focus')}
|
|
reorderable
|
|
/>
|
|
</div>
|
|
{:else}
|
|
<div class="w-full flex flex-col gap-y-1">
|
|
{#key redraw}
|
|
{#if Array.isArray(value)}
|
|
{#each value ?? [] as v, i}
|
|
{#if i < itemsLimit}
|
|
<div class="flex w-full items-center relative">
|
|
{#snippet deleteItemBtn()}
|
|
<button
|
|
transition:fade|local={{ duration: 100 }}
|
|
class="rounded-full p-1 bg-surface-secondary duration-200 hover:bg-surface-hover ml-2"
|
|
aria-label="Clear"
|
|
onclick={() => {
|
|
value = value.filter((_, index) => index !== i)
|
|
redraw += 1
|
|
}}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
{/snippet}
|
|
{#if itemsType?.type == 'number'}
|
|
<TextInput
|
|
inputProps={{ type: 'number', id: 'arg-input-number-array' }}
|
|
class="pr-8"
|
|
bind:value={value[i]}
|
|
/>
|
|
<div class="absolute z-10 right-1.5">
|
|
{@render deleteItemBtn()}
|
|
</div>
|
|
{:else if itemsType?.type == 'string' && itemsType?.contentEncoding == 'base64'}
|
|
<FileInput
|
|
class="w-full"
|
|
on:change={(x) =>
|
|
fileChangedInner(x.detail?.[0], (val) => (value[i] = val))}
|
|
multiple={false}
|
|
/>
|
|
{@render deleteItemBtn()}
|
|
{:else if itemsType?.type == 'object' && itemsType?.resourceType === undefined && itemsType?.properties === undefined && !(format?.startsWith('resource-') && resourceTypes?.includes(format.split('-')[1]))}
|
|
{#await import('$lib/components/JsonEditor.svelte')}
|
|
<Loader2 class="animate-spin" />
|
|
{:then Module}
|
|
<Module.default
|
|
code={JSON.stringify(v, null, 2)}
|
|
bind:value={value[i]}
|
|
/>
|
|
{/await}
|
|
{@render deleteItemBtn()}
|
|
{:else if Array.isArray(itemsType?.enum)}
|
|
<ArgEnum
|
|
create={extra['disableCreate'] != true}
|
|
on:focus={() => {
|
|
dispatch('focus')
|
|
}}
|
|
on:blur={(e) => {
|
|
dispatch('blur')
|
|
}}
|
|
{defaultValue}
|
|
valid={valid ?? true}
|
|
{disabled}
|
|
{autofocus}
|
|
bind:value={value[i]}
|
|
enum_={itemsType?.enum ?? []}
|
|
enumLabels={extra['enumLabels']}
|
|
/>
|
|
{@render deleteItemBtn()}
|
|
{:else if (itemsType?.type == 'resource' && itemsType?.resourceType && resourceTypes?.includes(itemsType.resourceType)) || (format?.startsWith('resource-') && resourceTypes?.includes(format.split('-')[1]))}
|
|
{@const resourceFormat =
|
|
itemsType?.type == 'resource' &&
|
|
itemsType.resourceType &&
|
|
resourceTypes.includes(itemsType.resourceType)
|
|
? `resource-${itemsType.resourceType}`
|
|
: format!}
|
|
<ObjectResourceInput
|
|
bind:value={value[i]}
|
|
format={resourceFormat}
|
|
defaultValue={undefined}
|
|
/>
|
|
{@render deleteItemBtn()}
|
|
{:else if itemsType?.type == 'resource'}
|
|
{#await import('$lib/components/JsonEditor.svelte')}
|
|
<Loader2 class="animate-spin" />
|
|
{:then Module}
|
|
<Module.default
|
|
bind:editor
|
|
on:focus={(e) => {
|
|
dispatch('focus')
|
|
}}
|
|
on:blur={(e) => {
|
|
dispatch('blur')
|
|
}}
|
|
code={JSON.stringify(v, null, 2)}
|
|
bind:value={value[i]}
|
|
/>
|
|
{/await}
|
|
{@render deleteItemBtn()}
|
|
{:else if itemsType?.type === 'object' && itemsType?.properties}
|
|
<div class="p-8 border rounded-md w-full">
|
|
<SchemaForm
|
|
lightHeaderFont
|
|
{onlyMaskPassword}
|
|
{disablePortal}
|
|
{disabled}
|
|
{prettifyHeader}
|
|
schema={getSchemaFromProperties(itemsType?.properties)}
|
|
bind:args={value[i]}
|
|
/>
|
|
</div>
|
|
{@render deleteItemBtn()}
|
|
{:else}
|
|
<TextInput
|
|
inputProps={{ type: 'text', id: 'arg-input-array' }}
|
|
class="pr-8"
|
|
bind:value={value[i]}
|
|
/>
|
|
<div class="absolute z-10 right-1.5">
|
|
{@render deleteItemBtn()}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
{#if value.length > itemsLimit}
|
|
<button onclick={() => (itemsLimit += 50)} class="text-xs py-2 text-blue-600"
|
|
>{itemsLimit}/{value.length}: Load 50 more...</button
|
|
>
|
|
{/if}
|
|
{:else if typeof value === 'string'}
|
|
{#if value.startsWith('$res:')}
|
|
{@render resourceInput()}
|
|
{:else}
|
|
<div class="text-red-500 text-xs">
|
|
Invalid string value: "{value}", expected array. Click add item to turn it
|
|
into an array.
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{/key}
|
|
</div>
|
|
<Button
|
|
variant="default"
|
|
color="light"
|
|
size="xs"
|
|
wrapperClasses="w-full {Array.isArray(value) && value.length > 0 ? 'mt-1.5' : ''}"
|
|
on:click={() => {
|
|
if (value == undefined || !Array.isArray(value)) {
|
|
value = []
|
|
}
|
|
if (itemsType?.type == 'number') {
|
|
value = value.concat(0)
|
|
} else if (
|
|
(itemsType?.type == 'object' &&
|
|
!(
|
|
format?.startsWith('resource-') &&
|
|
resourceTypes?.includes(format.split('-')[1])
|
|
)) ||
|
|
(itemsType?.type == 'resource' &&
|
|
!(itemsType?.resourceType && resourceTypes?.includes(itemsType?.resourceType)))
|
|
) {
|
|
value = value.concat({})
|
|
} else {
|
|
value = value.concat('')
|
|
}
|
|
}}
|
|
id="arg-input-add-item"
|
|
startIcon={{ icon: Plus }}
|
|
>
|
|
Add item
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
{#if !displayHeader}
|
|
<div class="block mt-2 pl-2">
|
|
<Toggle
|
|
on:change={(e) => {
|
|
// Once the user has changed the input type, we should not change it back automatically
|
|
if (!hasIsListJsonChanged) {
|
|
hasIsListJsonChanged = true
|
|
}
|
|
|
|
evalValueToRaw()
|
|
isListJson = !isListJson
|
|
}}
|
|
checked={isListJson}
|
|
textClass="text-secondary"
|
|
size="xs"
|
|
options={{ left: 'json' }}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else if inputCat == 'dynamic'}
|
|
<DynamicInput
|
|
name={label}
|
|
{otherArgs}
|
|
{helperScript}
|
|
{workspace}
|
|
bind:value
|
|
format={format ?? ''}
|
|
/>
|
|
{:else if inputCat == 'resource-object' && resourceTypes == undefined}
|
|
<span class="text-2xs text-primary">Loading resource types...</span>
|
|
{:else if inputCat == 'resource-object' && (resourceTypes == undefined || (format && format?.split('-').length > 1 && resourceTypes.includes(format?.substring('resource-'.length))))}
|
|
<!-- {JSON.stringify(value)} -->
|
|
<ObjectResourceInput
|
|
datatableAsPgResource={label === 'database'}
|
|
{disabled}
|
|
{workspace}
|
|
{defaultValue}
|
|
selectFirst={!noDefaultOnSelectFirst && required}
|
|
{disablePortal}
|
|
format={format ?? ''}
|
|
bind:value
|
|
bind:editor
|
|
onClear={() => {
|
|
defaultValue = null
|
|
}}
|
|
{showSchemaExplorer}
|
|
/>
|
|
{:else if inputCat == 'object' || inputCat == 'resource-object' || isListJson}
|
|
{#if oneOf && oneOf.length >= 2}
|
|
<div class="flex flex-col gap-2 w-full border rounded-md p-4">
|
|
{#if oneOf && oneOf.length >= 2}
|
|
<ToggleButtonGroup
|
|
selected={oneOfSelected}
|
|
wrap
|
|
class="mb-4"
|
|
on:selected={({ detail }) => {
|
|
oneOfSelected = detail
|
|
const selectedObjProperties =
|
|
oneOf?.find((o) => o.title == detail)?.properties ?? {}
|
|
const newValueKeys = Object.keys(selectedObjProperties)
|
|
const toKeep = {}
|
|
for (const key of newValueKeys) {
|
|
// Check if there is a select (enum) in the newly selected oneOf and if the current value is not in the enum, skip it
|
|
if (
|
|
!['kind', 'label'].includes(key) &&
|
|
selectedObjProperties[key]?.enum &&
|
|
value &&
|
|
value[key] !== undefined &&
|
|
!selectedObjProperties[key].enum.includes(value[key])
|
|
) {
|
|
continue
|
|
}
|
|
toKeep[key] = value[key]
|
|
}
|
|
const tagKey = oneOf.find((o) => Object.keys(o.properties ?? {}).includes('kind'))
|
|
? 'kind'
|
|
: 'label'
|
|
value = { ...toKeep, [tagKey]: detail }
|
|
}}
|
|
>
|
|
{#snippet children({ item })}
|
|
{#each oneOf as obj}
|
|
<ToggleButton value={obj.title ?? ''} label={obj.title} {item} />
|
|
{/each}
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
{#if oneOfSelected}
|
|
{@const objIdx = oneOf.findIndex((o) => o.title === oneOfSelected)}
|
|
{@const obj = oneOf[objIdx]}
|
|
{#if obj && obj.properties && Object.keys(obj.properties).length > 0}
|
|
{#key redraw}
|
|
{#if orderEditable}
|
|
<SchemaFormDnd
|
|
lightHeaderFont
|
|
{nestedClasses}
|
|
{onlyMaskPassword}
|
|
{disablePortal}
|
|
{disabled}
|
|
{prettifyHeader}
|
|
bind:schema={
|
|
() => ({
|
|
properties: obj.properties ?? {},
|
|
order: obj.order,
|
|
$schema: '',
|
|
required: obj.required ?? [],
|
|
type: 'object'
|
|
}),
|
|
() => {
|
|
dispatch('nestedChange')
|
|
}
|
|
}
|
|
bind:args={value}
|
|
hiddenArgs={[
|
|
oneOf?.find((o) => Object.keys(o.properties ?? {}).includes('kind'))
|
|
? 'kind'
|
|
: 'label'
|
|
]}
|
|
on:reorder={(e) => {
|
|
if (oneOf && oneOf[objIdx]) {
|
|
const keys = e.detail
|
|
oneOf[objIdx].order = keys
|
|
}
|
|
}}
|
|
on:nestedChange
|
|
{shouldDispatchChanges}
|
|
/>
|
|
{:else}
|
|
<SchemaForm
|
|
lightHeaderFont
|
|
{nestedClasses}
|
|
{onlyMaskPassword}
|
|
{disablePortal}
|
|
{disabled}
|
|
{prettifyHeader}
|
|
{chatInputEnabled}
|
|
hiddenArgs={['label', 'kind']}
|
|
schema={{
|
|
properties: obj.properties,
|
|
order: obj.order,
|
|
$schema: '',
|
|
required: obj.required ?? [],
|
|
type: 'object'
|
|
}}
|
|
bind:args={
|
|
() => value,
|
|
(v) => {
|
|
value = { ...v, [tagKey]: oneOfSelected }
|
|
}
|
|
}
|
|
{shouldDispatchChanges}
|
|
on:change={() => {
|
|
dispatch('nestedChange')
|
|
}}
|
|
on:nestedChange
|
|
/>
|
|
{/if}
|
|
{/key}
|
|
{#if !s3StorageConfigured && obj['x-no-s3-storage-workspace-warning']}
|
|
<Alert
|
|
type="warning"
|
|
title={obj['x-no-s3-storage-workspace-warning']}
|
|
size="xs"
|
|
titleClass="text-2xs"
|
|
/>
|
|
{/if}
|
|
{:else if disabled}
|
|
<textarea disabled></textarea>
|
|
{:else}
|
|
{#await import('$lib/components/JsonEditor.svelte')}
|
|
<Loader2 class="animate-spin" />
|
|
{:then Module}
|
|
<Module.default
|
|
bind:editor
|
|
on:focus={(e) => {
|
|
dispatch('focus')
|
|
}}
|
|
on:blur={(e) => {
|
|
dispatch('blur')
|
|
}}
|
|
code={rawValue}
|
|
on:changeValue={(e) => {
|
|
setNewValueFromCode(e.detail)
|
|
}}
|
|
/>
|
|
{/await}
|
|
{/if}
|
|
{/if}
|
|
{:else if disabled}
|
|
<textarea disabled></textarea>
|
|
{:else}
|
|
{#await import('$lib/components/JsonEditor.svelte')}
|
|
<Loader2 class="animate-spin" />
|
|
{:then Module}
|
|
<Module.default
|
|
bind:editor
|
|
on:focus={(e) => {
|
|
dispatch('focus')
|
|
}}
|
|
on:blur={(e) => {
|
|
dispatch('blur')
|
|
}}
|
|
code={rawValue}
|
|
on:change={(e) => {
|
|
value = e.detail
|
|
}}
|
|
/>
|
|
{/await}
|
|
{/if}
|
|
</div>
|
|
{:else if properties && Object.keys(properties).length > 0 && inputCat !== 'list'}
|
|
<div class={hideNested ? 'hidden' : 'px-4 pt-4 border rounded-md w-full'}>
|
|
{#if orderEditable}
|
|
<SchemaFormDnd
|
|
lightHeaderFont
|
|
{nestedClasses}
|
|
{onlyMaskPassword}
|
|
{disablePortal}
|
|
{disabled}
|
|
{prettifyHeader}
|
|
schema={{
|
|
properties,
|
|
$schema: '',
|
|
required: nestedRequired ?? [],
|
|
type: 'object',
|
|
order
|
|
}}
|
|
bind:args={value}
|
|
on:reorder={(e) => {
|
|
const keys = e.detail
|
|
order = keys
|
|
}}
|
|
diff={diffStatus && typeof diffStatus.diff === 'object' ? diffStatus.diff : {}}
|
|
on:acceptChange={(e) => {
|
|
dispatch('acceptChange', e.detail)
|
|
}}
|
|
on:rejectChange={(e) => {
|
|
dispatch('rejectChange', e.detail)
|
|
}}
|
|
on:nestedChange
|
|
nestedParent={{ label, nestedParent }}
|
|
{shouldDispatchChanges}
|
|
/>
|
|
{:else}
|
|
<SchemaForm
|
|
lightHeaderFont
|
|
{nestedClasses}
|
|
{onlyMaskPassword}
|
|
{disablePortal}
|
|
{disabled}
|
|
{prettifyHeader}
|
|
schema={{
|
|
properties,
|
|
order,
|
|
$schema: '',
|
|
required: nestedRequired ?? [],
|
|
type: 'object'
|
|
}}
|
|
bind:args={value}
|
|
diff={diffStatus && typeof diffStatus.diff === 'object' ? diffStatus.diff : {}}
|
|
nestedParent={{ label, nestedParent }}
|
|
on:acceptChange={(e) => {
|
|
dispatch('acceptChange', e.detail)
|
|
}}
|
|
on:rejectChange={(e) => {
|
|
dispatch('rejectChange', e.detail)
|
|
}}
|
|
on:change={() => {
|
|
dispatch('nestedChange')
|
|
}}
|
|
on:nestedChange
|
|
{shouldDispatchChanges}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
{:else if disabled}
|
|
<textarea disabled></textarea>
|
|
{:else}
|
|
{#await import('$lib/components/JsonEditor.svelte')}
|
|
<Loader2 class="animate-spin" />
|
|
{:then Module}
|
|
<Module.default
|
|
bind:editor
|
|
on:focus={(e) => {
|
|
dispatch('focus')
|
|
}}
|
|
on:blur={(e) => {
|
|
dispatch('blur')
|
|
}}
|
|
code={rawValue}
|
|
on:changeValue={(e) => {
|
|
setNewValueFromCode(e.detail)
|
|
}}
|
|
/>
|
|
{/await}
|
|
{/if}
|
|
{#if inputCat == 'list' && !displayHeader}
|
|
<div class="block mt-2.5 pl-2">
|
|
<Toggle
|
|
on:change={(e) => {
|
|
isListJson = !isListJson
|
|
}}
|
|
checked={isListJson}
|
|
textClass="text-secondary"
|
|
size="xs"
|
|
options={{ left: 'json' }}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{:else if inputCat == 'enum'}
|
|
<div class="flex flex-row w-full gap-1">
|
|
<ArgEnum
|
|
onClear={() => {
|
|
lastValue = undefined
|
|
value = undefined
|
|
}}
|
|
create={extra['disableCreate'] != true}
|
|
{defaultValue}
|
|
valid={valid ?? true}
|
|
{disabled}
|
|
bind:value
|
|
{enum_}
|
|
{autofocus}
|
|
on:focus={() => {
|
|
dispatch('focus')
|
|
}}
|
|
on:blur={(e) => {
|
|
dispatch('blur')
|
|
}}
|
|
enumLabels={extra['enumLabels']}
|
|
/>
|
|
</div>
|
|
{:else if inputCat == 'date'}
|
|
{#if format === 'date'}
|
|
<DateInput {disabled} {autofocus} bind:value dateFormat={extra?.['dateFormat']} />
|
|
{:else if format === 'naive-date-time'}
|
|
<DateTimeInput {disabled} useDropdown {autofocus} bind:value timezone="naive" />
|
|
{:else}
|
|
<DateTimeInput {disabled} useDropdown {autofocus} bind:value />
|
|
{/if}
|
|
{:else if isRawStringEditor(inputCat)}
|
|
{#if disabled}
|
|
<textarea disabled></textarea>
|
|
{:else}
|
|
<div class="border my-1 mb-4 w-full">
|
|
{#await import('$lib/components/SimpleEditor.svelte')}
|
|
<Loader2 class="animate-spin" />
|
|
{:then Module}
|
|
<Module.default
|
|
on:focus={(e) => {
|
|
dispatch('focus')
|
|
}}
|
|
on:blur={(e) => {
|
|
dispatch('blur')
|
|
}}
|
|
on:change={(e) => {
|
|
setNewValueFromCode(e.detail?.code)
|
|
}}
|
|
bind:this={editor}
|
|
lang={inputCat}
|
|
code={typeof rawValue == 'string' ? rawValue : JSON.stringify(rawValue, null, 2)}
|
|
autoHeight
|
|
/>
|
|
{/await}
|
|
</div>
|
|
{/if}
|
|
{:else if inputCat == 'base64'}
|
|
<div class="flex flex-col w-full">
|
|
<FileInput
|
|
on:change={(x) => fileChangedInner(x.detail?.[0], (val) => (value = val))}
|
|
multiple={false}
|
|
/>
|
|
{#if value?.length}
|
|
<div class="text-2xs text-primary mt-1">
|
|
File length: {value.length} base64 chars ({(value.length / 1024 / 1024).toFixed(2)}MB)
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else if inputCat == 'resource-string'}
|
|
<ResourcePicker
|
|
selectFirst={noDefaultOnSelectFirst}
|
|
{disablePortal}
|
|
{workspace}
|
|
bind:value
|
|
initialValue={defaultValue}
|
|
resourceType={format && format.split('-').length > 1
|
|
? format.substring('resource-'.length)
|
|
: undefined}
|
|
{showSchemaExplorer}
|
|
/>
|
|
{:else if inputCat == 'ai-provider'}
|
|
<AIProviderPicker bind:value {disabled} {actions} />
|
|
{:else if inputCat == 'email'}
|
|
<input
|
|
{autofocus}
|
|
onfocus={bubble('focus')}
|
|
onblur={bubble('blur')}
|
|
{disabled}
|
|
type="email"
|
|
class={valid
|
|
? ''
|
|
: 'border border-red-700 border-opacity-30 focus:border-red-700 focus:border-opacity-3'}
|
|
placeholder={placeholder ?? defaultValue ?? ''}
|
|
bind:value
|
|
/>
|
|
{:else if inputCat == 'string'}
|
|
<div class="flex flex-col w-full">
|
|
<div class="flex flex-row w-full items-center justify-between relative">
|
|
{#if password || extra?.['password'] == true}
|
|
{#if onlyMaskPassword}
|
|
{#if value && typeof value == 'string' && value?.startsWith('$var:')}
|
|
<input type="text" bind:value />
|
|
{:else}
|
|
<Password
|
|
{disabled}
|
|
minRows={extra?.['minRows']}
|
|
bind:password={value}
|
|
placeholder={placeholder ?? defaultValue ?? ''}
|
|
/>
|
|
{/if}
|
|
{:else}
|
|
<PasswordArgInput {disabled} minRows={extra?.['minRows']} bind:value />
|
|
{/if}
|
|
{:else}
|
|
{#key extra?.['minRows']}
|
|
<TextInput
|
|
inputProps={{
|
|
autofocus,
|
|
onfocus: () => dispatch('focus'),
|
|
onblur: () => dispatch('blur'),
|
|
disabled,
|
|
onkeydown: onKeyDown,
|
|
placeholder: placeholder ?? defaultValue ?? '',
|
|
rows: extra?.['minRows'] ? extra['minRows']?.toString() : '1'
|
|
}}
|
|
bind:value
|
|
{error}
|
|
unifiedHeight={false}
|
|
underlyingInputEl="textarea"
|
|
/>
|
|
{/key}
|
|
{/if}
|
|
{#if !disabled && itemPicker && extra?.['disableVariablePicker'] != true}
|
|
<Button
|
|
iconOnly
|
|
startIcon={{ icon: DollarSign }}
|
|
unifiedSize="sm"
|
|
onClick={() => {
|
|
pickForField = label
|
|
itemPicker?.openDrawer?.()
|
|
}}
|
|
wrapperClasses={twMerge(
|
|
'opacity-0 group-hover:opacity-100 transition-opacity absolute bg-surface-input',
|
|
password || extra?.['password'] == true ? 'right-8 ' : 'right-2'
|
|
)}
|
|
variant="subtle"
|
|
title="Insert a Variable"
|
|
/>
|
|
{/if}
|
|
</div>
|
|
{@render variableInput()}
|
|
{@render resourceInput()}
|
|
</div>
|
|
{/if}
|
|
{@render actions?.()}
|
|
</div>
|
|
|
|
{#if isNonStringSecret}
|
|
{#if typeof value === 'string' && value.startsWith('$jsonvar:')}
|
|
<div class="text-2xs text-tertiary">
|
|
Sensitive — stored as secret: <code class="text-2xs">{value.slice('$jsonvar:'.length)}</code
|
|
>
|
|
</div>
|
|
{:else}
|
|
<div class="text-2xs text-tertiary italic">Sensitive — will be stored as secret on submit</div
|
|
>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if !compact || (error && error != '')}
|
|
<div class="text-right text-xs leading-3 text-red-600 dark:text-red-400 mb-2">
|
|
{#if disabled || error === ''}
|
|
|
|
{:else}
|
|
{error}
|
|
{/if}
|
|
</div>
|
|
{:else if !noMargin}
|
|
<div class="mb-2"></div>
|
|
{/if}
|
|
|
|
{#if !s3StorageConfigured && extra['x-no-s3-storage-workspace-warning']}
|
|
<Alert
|
|
type="warning"
|
|
title={extra['x-no-s3-storage-workspace-warning']}
|
|
size="xs"
|
|
titleClass="text-2xs"
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
input::-webkit-outer-spin-button,
|
|
input::-webkit-inner-spin-button {
|
|
-webkit-appearance: none !important;
|
|
margin: 0;
|
|
}
|
|
</style>
|