Files
windmill/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.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

401 lines
12 KiB
Svelte

<script lang="ts">
import { Alert } from '$lib/components/common'
import { getContext, setContext } from 'svelte'
import { watch } from 'runed'
import type { PropPickerWrapperContext } from '../propPicker/PropPickerWrapper.svelte'
import { writable } from 'svelte/store'
import type { FlowEditorContext } from '../types'
import { Button } from '$lib/components/common'
import { DollarSign, Plus, Trash2 } from 'lucide-svelte'
import FlowCard from '../common/FlowCard.svelte'
import JsonEditor from '$lib/components/JsonEditor.svelte'
import Label from '$lib/components/Label.svelte'
import Select from '$lib/components/select/Select.svelte'
import ItemPicker from '$lib/components/ItemPicker.svelte'
import ResourcePicker from '$lib/components/ResourcePicker.svelte'
import { VariableService } from '$lib/gen'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
noEditor: boolean
}
type EnvVarType = 'string' | 'json' | 'resource'
interface EnvVarEntry {
id: string
key: string
value: any
type: EnvVarType
displayValue: string
error?: string
}
let { noEditor }: Props = $props()
const { flowStore, opWorkspace } = getContext<FlowEditorContext>('FlowEditorContext')
let opWs = $derived(opWorkspace?.() ?? $operatingWorkspace)
if (!flowStore.val.value.flow_env) {
flowStore.val.value.flow_env = {}
}
let flowEnvVarsMap = $derived(new Map(Object.entries(flowStore.val.value.flow_env || {})))
function determineValueType(value: any): EnvVarType {
if (typeof value === 'string') {
if (value.startsWith('$res:')) {
return 'resource'
}
try {
JSON.parse(value)
return value.trim().startsWith('{') ||
value.trim().startsWith('[') ||
value.trim().startsWith('"')
? 'json'
: 'string'
} catch {
return 'string'
}
}
return 'json'
}
let flowEnvTypes = $state<Record<string, EnvVarType>>({})
const typeOptions: { label: string; value: EnvVarType }[] = [
{ label: 'String', value: 'string' },
{ label: 'JSON', value: 'json' },
{ label: 'Resource', value: 'resource' }
]
// Track resource paths separately for bind:value with ResourcePicker
let resourcePaths = $state<Record<string, string | undefined>>({})
// Initialize resourcePaths from existing flow_env values
for (const [key, value] of Object.entries(flowStore.val.value.flow_env || {})) {
if (typeof value === 'string' && value.startsWith('$res:')) {
resourcePaths[key] = value.substring('$res:'.length)
}
}
// Initialize types for new keys and sync resourcePaths → flow_env
$effect(() => {
for (const [key, value] of flowEnvVarsMap.entries()) {
if (!flowEnvTypes[key]) {
flowEnvTypes[key] = determineValueType(value)
}
}
for (const [key, path] of Object.entries(resourcePaths)) {
if (flowStore.val.value.flow_env && flowEnvTypes[key] === 'resource') {
const newVal = '$res:' + (path || '')
if (flowStore.val.value.flow_env[key] !== newVal) {
flowStore.val.value.flow_env[key] = newVal
flowStore.val = flowStore.val
}
}
}
})
// Convert values when user changes the type dropdown
let prevTypes: Record<string, EnvVarType> = {}
$effect(() => {
for (const [key, type] of Object.entries(flowEnvTypes)) {
if (prevTypes[key] && prevTypes[key] !== type) {
updateEnvType(key, type)
}
prevTypes[key] = type
}
})
let flowEnvEntries = $derived(
Array.from(flowEnvVarsMap.entries()).map(([key, value]): EnvVarEntry => {
const stringValue = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
const type = flowEnvTypes[key] || determineValueType(value)
return {
id: key,
key,
value,
type,
displayValue: stringValue,
error: undefined
}
})
)
function addEnvVar() {
const existingKeys = Array.from(flowEnvVarsMap.keys())
let counter = 1
let newKey = `VAR_${counter}`
while (existingKeys.includes(newKey)) {
counter++
newKey = `VAR_${counter}`
}
if (!flowStore.val.value.flow_env) {
flowStore.val.value.flow_env = {}
}
flowStore.val.value.flow_env[newKey] = ''
flowEnvTypes[newKey] = 'string'
flowStore.val = flowStore.val
}
function removeEnvVar(key: string) {
if (flowStore.val.value.flow_env && key in flowStore.val.value.flow_env) {
delete flowStore.val.value.flow_env[key]
delete flowEnvTypes[key]
delete resourcePaths[key]
flowStore.val = flowStore.val
}
}
function updateEnvValue(key: string, value: string, type: EnvVarType) {
if (flowStore.val.value.flow_env) {
if (type === 'json') {
try {
const parsed = JSON.parse(value)
flowStore.val.value.flow_env[key] = parsed
} catch (e) {
flowStore.val.value.flow_env[key] = value
}
} else {
flowStore.val.value.flow_env[key] = value
}
flowStore.val = flowStore.val
}
}
function updateEnvKey(oldKey: string, newKey: string) {
if (flowStore.val.value.flow_env && oldKey !== newKey && newKey.trim() !== '') {
const value = flowStore.val.value.flow_env[oldKey]
const type = flowEnvTypes[oldKey] || 'string'
const newEnvVars: Record<string, any> = {}
for (const [k, v] of flowEnvVarsMap.entries()) {
if (k === oldKey) {
newEnvVars[newKey] = value
} else {
newEnvVars[k] = v
}
}
flowStore.val.value.flow_env = newEnvVars
delete flowEnvTypes[oldKey]
flowEnvTypes[newKey] = type
// Move resource path if applicable
if (type === 'resource' && oldKey in resourcePaths) {
resourcePaths[newKey] = resourcePaths[oldKey]
delete resourcePaths[oldKey]
}
flowStore.val = flowStore.val
}
}
function updateEnvType(key: string, newType: EnvVarType) {
if (flowStore.val.value.flow_env && key in flowStore.val.value.flow_env) {
flowEnvTypes[key] = newType
if (newType === 'resource') {
flowStore.val.value.flow_env[key] = '$res:'
resourcePaths[key] = ''
} else if (newType === 'json') {
delete resourcePaths[key]
const currentValue = flowStore.val.value.flow_env[key]
if (typeof currentValue === 'string') {
try {
flowStore.val.value.flow_env[key] = JSON.parse(currentValue)
} catch {
// keep as string if not valid JSON
}
}
} else {
delete resourcePaths[key]
const currentValue = flowStore.val.value.flow_env[key]
if (typeof currentValue !== 'string') {
flowStore.val.value.flow_env[key] = JSON.stringify(currentValue, null, 2)
}
}
flowStore.val = flowStore.val
}
}
function setVarPath(key: string, path: string) {
if (flowStore.val.value.flow_env) {
flowStore.val.value.flow_env[key] = '$var:' + path
flowStore.val = flowStore.val
}
}
let variablePicker: ItemPicker | undefined = $state(undefined)
let pickForKey: string | undefined = $state(undefined)
watch(
() => opWs,
() => variablePicker?.reloadItems()
)
setContext<PropPickerWrapperContext>('PropPickerWrapper', {
inputMatches: writable(undefined),
connectProp: () => {},
propPickerConfig: writable(undefined),
clearConnect: () => {},
openPicker: () => {},
pickerMode: () => 'pane' as const,
setPickTarget: () => {},
onPick: () => {},
exprBeingEdited: writable([])
})
</script>
<div class="min-h-full">
<FlowCard {noEditor} title="Flow Env Variables">
<div class="min-h-full flex-1">
<Alert type="info" title="Flow Env Variables" class="m-4">
Flow envs can be referenced in any flow step input using the syntax{' '}
<code>flow_env.VARIABLE_NAME</code> or <code>flow_env["VARIABLE_NAME"]</code>. These
variables are available in the property picker and can be used in JavaScript expressions and
input bindings. String values can link to workspace variables using the <DollarSign
size={12}
class="inline"
/> button. Resource type references workspace resources resolved at runtime.
</Alert>
{#if flowEnvEntries.length === 0}
<Alert type="warning" title="No flow env variables" class="m-4">
This flow has no flow env variables defined. Click "Add Variable" to create your first
flow env variable.
</Alert>
{:else}
<div class="space-y-4 p-4">
{#each flowEnvEntries as entry (entry.id)}
<div class="flex flex-col gap-4 p-4 border rounded-lg bg-surface-secondary">
<div class="flex items-end gap-3">
<div class="flex-1 min-w-0 max-w-xs">
<Label label="Variable Name">
<input
type="text"
value={entry.key}
onblur={(e) => {
const newKey = e.currentTarget.value.trim()
if (newKey !== entry.key && newKey !== '') {
updateEnvKey(entry.key, newKey)
}
}}
disabled={noEditor}
class="input w-full"
placeholder="VARIABLE_NAME"
/>
</Label>
</div>
<Label label="Type">
<Select
bind:value={flowEnvTypes[entry.key]}
items={typeOptions}
disabled={noEditor}
size="sm"
class="text-sm"
/>
</Label>
{#if !noEditor}
<Button
size="sm"
color="red"
startIcon={{ icon: Trash2 }}
onClick={() => removeEnvVar(entry.key)}
>
Remove
</Button>
{/if}
</div>
<Label label="Value">
{#if entry.type === 'resource'}
<ResourcePicker
bind:value={resourcePaths[entry.key]}
disabled={noEditor}
workspace={opWs}
/>
{:else if entry.type === 'json'}
<div class="w-full">
<JsonEditor
bind:code={entry.displayValue}
disabled={noEditor}
class="min-h-[60px] max-h-[200px]"
on:change={() => {
updateEnvValue(entry.key, entry.displayValue, 'json')
}}
/>
</div>
{:else}
<div class="relative group w-full">
<input
type="text"
value={entry.displayValue}
oninput={(e) => updateEnvValue(entry.key, e.currentTarget.value, 'string')}
disabled={noEditor}
class="input w-full"
placeholder="Variable value"
/>
{#if !noEditor}
<Button
iconOnly
startIcon={{ icon: DollarSign }}
unifiedSize="sm"
onClick={() => {
pickForKey = entry.key
variablePicker?.openDrawer?.()
}}
wrapperClasses="opacity-0 group-hover:opacity-100 transition-opacity absolute right-2 top-1/2 -translate-y-1/2 bg-surface-input"
variant="subtle"
title="Insert a Variable"
/>
{/if}
</div>
{#if typeof entry.value === 'string' && entry.value.startsWith('$var:') && entry.value.length > 5}
<div class="text-2xs text-tertiary">
Linked to variable <a
href="/variables#{entry.value.slice(5)}"
target="_blank"
class="text-accent underline font-normal">{entry.value.slice(5)}</a
>
</div>
{/if}
{/if}
</Label>
</div>
{/each}
</div>
{/if}
<div class=" p-4">
{#if !noEditor}
<Button size="sm" startIcon={{ icon: Plus }} onClick={addEnvVar} color="light">
Add Variable
</Button>
{/if}
</div>
</div>
</FlowCard>
</div>
<ItemPicker
bind:this={variablePicker}
pickCallback={(path, _) => {
if (pickForKey) {
setVarPath(pickForKey, path)
pickForKey = undefined
}
}}
itemName="Variable"
extraField="path"
loadItems={async () =>
(await VariableService.listVariable({ workspace: opWs ?? '' })).map((x) => ({
name: x.path,
...x
}))}
/>