Files
windmill/frontend/src/lib/components/ModulePreviewForm.svelte
T
6186a0645d feat: edit variables, resources and triggers in their own session tab (#11206)
* feat: edit variables, resources and triggers in their own session tab

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: take a trigger's flow kind from its config and name the written trigger

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: one operating-workspace context for editors acting on a session's workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: reload page item tabs only in sessions acting on the tool's workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: resolve open in workspace for every preview tab kind in one place

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: guard every component under a session editor against the navigation workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: show a page item tab whose item is gone instead of mounting its editor

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: follow a saved page item in the tab's own workspace and restore in-frame rows

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: close a page item tab from its editor header

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: judge trigger permissions by the user acting in the session workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: judge every session-editor permission by the user acting in that workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep session permissions reactive to the acting role and restore tabs as last seen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: default Path's owner controls to the user acting in its workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-09-19 23:29:14 +02:00

241 lines
8.8 KiB
Svelte

<script lang="ts">
import type { Schema } from '$lib/common'
import ResizeTransitionWrapper from './common/ResizeTransitionWrapper.svelte'
import { allTrue } from '$lib/utils'
import { RefreshCw } from 'lucide-svelte'
import ArgInput from './ArgInput.svelte'
import { Button } from './common'
import { getContext, untrack } from 'svelte'
import type { FlowEditorContext } from './flows/types'
import { evalValue } from './flows/utils.svelte'
import { memoryPropertyFor } from './flows/flowInfers'
import type { FlowModule } from '$lib/gen'
import type { PickableProperties } from './flows/previousResults'
import type SimpleEditor from './SimpleEditor.svelte'
import { getResourceTypes } from './resourceTypesStore'
import { twMerge } from 'tailwind-merge'
import { AGENT_FIELDS, initialVisibleAgentFields } from './flows/agentFormFields'
import { openAgentFields } from './flows/content/AiAgentStepInputs.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
schema: Schema | { properties?: Record<string, any>; required?: string[] }
mod: FlowModule
pickableProperties: PickableProperties | undefined
isValid?: boolean
autofocus?: boolean
focusArg?: string
/** Identifies the step whose agent form this one accompanies, so it can offer the fields that
* form has open. Same key `AiAgentStepInputs` is given. */
openFieldsKey?: string
/** Fields to offer whatever the step holds, for a surface where nothing else can set them
* (`AGENT_EDITOR_RUN_INPUTS`). */
runInputKeys?: readonly string[]
}
let {
schema,
mod,
pickableProperties,
isValid = $bindable(true),
autofocus = false,
focusArg = undefined,
openFieldsKey = undefined,
runInputKeys = []
}: Props = $props()
const { stepsInputArgs, flowStateStore, flowStore, previewArgs, opWorkspace } =
getContext<FlowEditorContext>('FlowEditorContext')
let opWs = $derived(opWorkspace?.() ?? $operatingWorkspace)
let inputCheck: { [id: string]: boolean } = $state({})
$effect(() => {
isValid = allTrue(inputCheck) ?? false
})
/** An agent asks for the same fields here that its own form shows: a setting the step leaves
* unset is not something a run needs told, and listing all eleven buries the message under the
* configuration. What the step configures stays, as it does on any other step, along with the
* rows its form has open — a field added there and left at its default reads as unset from the
* transforms alone, and this form has no add-field control to get it back. `runInputKeys` is
* for a surface whose form cannot open a row at all. A schema key the field registry doesn't
* know is kept, so a new one is never silently dropped. */
let schemaKeys = $derived(Object.keys(schema?.properties ?? {}))
// A legacy memory kind this step still holds stays one of the options, or the one-of field would
// turn the test run's memory off.
let isAgent = $derived((mod.value as { type?: string })?.type === 'aiagent')
let visibleKeys = $derived.by(() => {
const all = schemaKeys
if ((mod.value as { type?: string })?.type !== 'aiagent') return all
const transforms = (mod.value as { input_transforms?: Record<string, unknown> })
?.input_transforms
const visible = initialVisibleAgentFields(transforms, schema?.properties)
for (const key of openAgentFields(openFieldsKey)) visible.add(key)
for (const key of runInputKeys) visible.add(key)
const known = new Set(AGENT_FIELDS.map((f) => f.key))
// Listed in the agent form's order rather than the schema's, so the two read the same.
const position = new Map(AGENT_FIELDS.map((f, i) => [f.key, i]))
return all
.filter((key) => !known.has(key) || visible.has(key))
.sort((a, b) => (position.get(a) ?? Infinity) - (position.get(b) ?? Infinity))
})
let keys: string[] = $state([])
$effect(() => {
let lkeys = visibleKeys
if (schema?.properties && JSON.stringify(lkeys) != JSON.stringify(keys)) {
keys = lkeys
// Pruned against the schema rather than against what is shown. What a run was given for a
// field lives only here, so dropping it when the field merely stops being displayed would
// discard it: an agent hides the settings its step leaves unset, and clearing one in the
// Inputs tab hides it.
untrack(() => stepsInputArgs?.removeExtraKey(mod.id, schemaKeys))
}
})
/** Whether re-evaluating has anything to restore. A field the step configures nothing for
* evaluates to blank, so the control would only clear what was typed to run with. */
function hasConfiguredInput(argName: string): boolean {
const transform = (mod.value as any)?.input_transforms?.[argName]
if (!transform) return false
return transform.type === 'javascript' ? !!transform.expr : transform.value !== undefined
}
function plugIt(argName: string) {
stepsInputArgs?.setEvaluatedStepArg(
mod.id,
argName,
$state.snapshot(evalValue(argName, mod, pickableProperties, true))
)
}
let editor: Record<string, SimpleEditor | undefined> = $state({})
// Animation and highlighting for focusArg
let animateArg: string | undefined = $state(undefined)
$effect(() => {
if (focusArg) {
// Add a slight delay to ensure the form is rendered
setTimeout(() => {
const argElement = document.querySelector(`[data-arg="${focusArg}"]`)
if (argElement) {
// Add highlight animation
animateArg = focusArg
argElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
// Focus the input if it exists
const input = argElement.querySelector('input, textarea, select') as
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement
| null
if (input) {
input.focus()
}
// Remove highlight after animation
setTimeout(() => {
animateArg = undefined
}, 2000)
}
}, 200)
}
})
let resourceTypes: string[] | undefined = $state(undefined)
async function loadResourceTypes() {
resourceTypes = await getResourceTypes()
}
loadResourceTypes()
let initialized = $state(false)
$effect.pre(() => {
if (!initialized) {
if (stepsInputArgs) {
stepsInputArgs?.updateStepArgs(mod.id, flowStateStore.val, flowStore?.val, previewArgs?.val)
initialized = true
}
}
})
</script>
<div class="w-full pt-2" data-popover>
{#if initialized}
{#if keys.length > 0}
{#each keys as argName, i (argName)}
{#if Object.keys(schema.properties ?? {}).includes(argName)}
<ResizeTransitionWrapper
vertical
class={twMerge(
'flex gap-2',
animateArg === argName && 'animate-pulse ring-2 ring-offset-2 ring-blue-500 rounded'
)}
innerClass="w-full"
outerDivProps={{ 'data-arg': argName }}
>
{#if schema?.properties?.[argName]}
<ArgInput
{resourceTypes}
minW={false}
autofocus={autofocus && !focusArg && i == 0}
label={argName}
description={schema.properties[argName].description}
bind:value={
() => stepsInputArgs?.getStepInputArgs(mod.id, argName),
(v) => stepsInputArgs?.setStepInputArgs(mod.id, argName, v)
}
type={schema.properties[argName].type}
oneOf={isAgent && argName === 'memory'
? memoryPropertyFor(
schema.properties[argName],
stepsInputArgs?.getStepInputArgs(mod.id, argName)
)?.oneOf
: schema.properties[argName].oneOf}
required={schema?.required?.includes(argName)}
pattern={schema.properties[argName].pattern}
bind:editor={editor[argName]}
bind:valid={inputCheck[argName]}
defaultValue={schema.properties[argName].default}
enum_={schema.properties[argName].enum}
format={schema.properties[argName].format}
contentEncoding={schema.properties[argName].contentEncoding}
properties={schema.properties[argName].properties}
nestedRequired={schema.properties[argName].required}
itemsType={schema.properties[argName].items}
extra={schema.properties[argName]}
nullable={schema.properties[argName].nullable}
title={schema.properties[argName].title}
placeholder={schema.properties[argName].placeholder}
workspace={opWs}
>
{#snippet fieldHeaderActions()}
{#if stepsInputArgs?.isArgManuallySet(mod.id, argName) && hasConfiguredInput(argName)}
<Button
on:click={() => {
plugIt(argName)
}}
size="xs2"
variant="contained"
color="light"
title="Re-evaluate input step"><RefreshCw size={12} /></Button
>
{/if}
{/snippet}
</ArgInput>
{/if}
</ResizeTransitionWrapper>
{/if}
{/each}
{/if}
{:else}
<div class="text-center text-sm text-primary"> Loading test step arguments... </div>
{/if}
</div>