Files
windmill/frontend/src/lib/components/propertyPicker/PropPicker.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

458 lines
13 KiB
Svelte

<script lang="ts">
import { ResourceService, VariableService } from '$lib/gen'
import { getContext, onDestroy, untrack } from 'svelte'
import { Badge, Button } from '../common'
import type { PropPickerWrapperContext } from '../flows/propPicker/PropPickerWrapper.svelte'
import ObjectViewer from './ObjectViewer.svelte'
import { keepByKey } from './utils'
import type { PickableProperties } from '../flows/previousResults'
import ClearableInput from '../common/clearableInput/ClearableInput.svelte'
import { filterNestedObject } from '../flows/previousResults'
import type { PropPickerContext } from '../prop_picker'
import Scrollable from '../Scrollable.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
pickableProperties: PickableProperties
displayContext?: boolean
error?: boolean
allowCopy?: boolean
previousId?: string | undefined
result?: any | undefined
extraResults?: any
}
let {
pickableProperties,
displayContext = true,
error = false,
allowCopy = false,
previousId = undefined,
result = undefined,
extraResults = undefined
}: Props = $props()
let variables: Record<string, string> = $state({})
let resources: Record<string, any> = $state({})
let displayVariable = $state(false)
let displayResources = $state(false)
let allResultsCollapsed = $state(true)
let collapsableInitialState:
| {
allResultsCollapsed: boolean
displayVariable: boolean
displayResources: boolean
}
| undefined
const EMPTY_STRING = ''
let search = $state('')
const {
propPickerConfig,
inputMatches,
exprBeingEdited: propsBeingEdited
} = getContext<PropPickerWrapperContext>('PropPickerWrapper')
const { pickablePropertiesFiltered } = getContext<PropPickerContext>('PropPickerContext')
$effect(() => {
$pickablePropertiesFiltered = pickableProperties
})
let flowInputsFiltered: any = $state(untrack(() => pickableProperties).flow_input)
let resultByIdFiltered: any = $state(untrack(() => pickableProperties).priorIds)
let flowEnvFiltered: any = $state(untrack(() => pickableProperties).flow_env)
let timeout: number | undefined
function onSearch(search: string) {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
flowInputsFiltered =
search === EMPTY_STRING
? pickableProperties.flow_input
: keepByKey(pickableProperties.flow_input, search)
resultByIdFiltered =
search === EMPTY_STRING
? pickableProperties.priorIds
: keepByKey(pickableProperties.priorIds, search)
flowEnvFiltered =
search === EMPTY_STRING
? pickableProperties.flow_env
: keepByKey(pickableProperties.flow_env, search)
}, 50)
}
let suggestedPropsFiltered = $derived(
$propPickerConfig && $propPickerConfig.propName
? keepByKey(pickableProperties.priorIds, $propPickerConfig.propName ?? '')
: undefined
)
$effect(() => {
search != undefined && untrack(() => onSearch(search))
})
async function loadVariables() {
variables = Object.fromEntries(
(
await VariableService.listVariable({
workspace: $operatingWorkspace ?? ''
})
).map((variable) => [variable.path, variable.is_secret ? '***' : (variable.value ?? '')])
)
}
async function loadResources() {
resources = Object.fromEntries(
(
await ResourceService.listResource({
workspace: $operatingWorkspace ?? ''
})
).map((resource) => [resource.path, resource.description ?? ''])
)
}
let filteringFlowInputsOrResult = $state('')
async function filterPickableProperties() {
if ($propsBeingEdited?.length == 0 || $propPickerConfig || !$inputMatches?.length) {
if (search === EMPTY_STRING) {
flowInputsFiltered = pickableProperties.flow_input
resultByIdFiltered = pickableProperties.priorIds
flowEnvFiltered = pickableProperties.flow_env
}
filteringFlowInputsOrResult = ''
return
}
if (!$inputMatches?.some((match) => match.word === 'flow_input')) {
flowInputsFiltered = {}
}
if (!$inputMatches?.some((match) => match.word === 'results')) {
resultByIdFiltered = {}
}
if (!$inputMatches?.some((match) => match.word === 'flow_env')) {
if (search === EMPTY_STRING) {
flowEnvFiltered = pickableProperties.flow_env
}
}
if ($inputMatches?.length == 1) {
filteringFlowInputsOrResult = $inputMatches[0].value
if ($inputMatches[0].word === 'flow_input') {
flowInputsFiltered = pickableProperties.flow_input
let [, ...nestedKeys] = $inputMatches[0].value.split('.')
let filtered = filterNestedObject(flowInputsFiltered, nestedKeys)
if (Object.keys(filtered).length > 0) {
flowInputsFiltered = filtered
}
} else if ($inputMatches[0].word === 'results') {
resultByIdFiltered = pickableProperties.priorIds
let [, ...nestedKeys] = $inputMatches[0].value.split('.')
let filtered = filterNestedObject(resultByIdFiltered, nestedKeys)
if (Object.keys(filtered).length > 0) {
resultByIdFiltered = filtered
}
} else if ($inputMatches[0].word === 'flow_env') {
flowEnvFiltered = pickableProperties.flow_env
let [, ...nestedKeys] = $inputMatches[0].value.split('.')
let filtered = filterNestedObject(flowEnvFiltered, nestedKeys)
if (Object.keys(filtered).length > 0) {
flowEnvFiltered = filtered
}
}
} else {
filteringFlowInputsOrResult = ''
}
// if ($pickablePropertiesFiltered) {
// resultByIdFiltered && ($pickablePropertiesFiltered.priorIds = resultByIdFiltered)
// flowInputsFiltered && ($pickablePropertiesFiltered.flow_input = flowInputsFiltered)
// }
}
async function updateCollapsable() {
if (!$inputMatches || $inputMatches.length !== 1) {
resetCollapsable()
return
}
if (!collapsableInitialState) {
collapsableInitialState = {
allResultsCollapsed,
displayVariable,
displayResources
}
}
if ($inputMatches[0].word === 'variable') {
await loadVariables()
displayVariable = true
return
}
if ($inputMatches[0].word === 'resource') {
await loadResources()
displayResources = true
return
}
if ($inputMatches[0].word === 'results') {
allResultsCollapsed = false
return
}
}
function resetCollapsable() {
if (!collapsableInitialState) {
return
}
;({ allResultsCollapsed, displayVariable, displayResources } = collapsableInitialState)
collapsableInitialState = undefined
}
async function updateState() {
await filterPickableProperties()
await updateCollapsable()
}
$effect(() => {
;[search, $inputMatches, $propPickerConfig, pickableProperties, $propsBeingEdited]
untrack(() => updateState())
})
onDestroy(() => {
clearTimeout(timeout)
})
const categoryContentClasses = 'overflow-y-auto pb-4'
const categoryTitleClasses = 'font-semibold text-xs text-emphasis'
</script>
<div class="flex flex-col h-full">
<div class="px-1 pb-1">
<ClearableInput bind:value={search} placeholder="Search prop..." />
</div>
<!-- <div
class="px-2 pt-2 grow relative"
class:bg-surface-secondary={!$propPickerConfig && !alwaysOn} -->
<Scrollable scrollableClass="grow relative min-h-0 px-1 xl:px-2 py-1 shrink">
<!-- <div
class="px-2 pt-2 grow relative"
class:bg-surface-secondary={!$propPickerConfig && !alwaysOn}
> -->
<!-- <pre class="text-2xs w-full"
>{JSON.stringify({ pickableProperties, resultByIdFiltered }, null, 2)}</pre
> -->
{#if filteringFlowInputsOrResult}
<div class="absolute bottom-0 right-0">
<Badge small>filter: {filteringFlowInputsOrResult}</Badge>
</div>
{/if}
{#if result != undefined}
<span class={categoryTitleClasses}>Step Result</span>
<div class={categoryContentClasses}>
<ObjectViewer
{allowCopy}
json={{ result, ...(extraResults ? extraResults : {}) }}
on:select
/>
</div>
{/if}
{#if flowInputsFiltered && Object.keys(flowInputsFiltered ?? {}).length > 0}
<span class={categoryTitleClasses}>Flow Input</span>
<div class={categoryContentClasses}>
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
json={flowInputsFiltered}
prefix="flow_input"
on:select
/>
</div>
{/if}
{#if flowEnvFiltered && Object.keys(flowEnvFiltered ?? {}).length > 0}
<span class={categoryTitleClasses}>Flow Env Variables</span>
<div class={categoryContentClasses}>
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
json={flowEnvFiltered}
prefix="flow_env"
on:select
/>
</div>
{/if}
{#if error}
<span class={categoryTitleClasses}>Error</span>
<div class={categoryContentClasses}>
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
json={{
error: {
message: 'The error message',
name: 'The error name',
stack: 'The error stack',
step_id: 'The step id'
}
}}
on:select
/>
</div>
{#if Object.keys(pickableProperties.priorIds).length > 0}
{#if suggestedPropsFiltered && Object.keys(suggestedPropsFiltered).length > 0}
<span class={categoryTitleClasses}>Suggested Results</span>
<div class={categoryContentClasses}>
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
collapsed={false}
json={suggestedPropsFiltered}
prefix="results"
on:select
/>
</div>
{/if}
<span class={categoryTitleClasses}>Results</span>
<div class={categoryContentClasses}>
<ObjectViewer
expandedEvenOnLevel0={previousId}
{allowCopy}
pureViewer={!$propPickerConfig}
collapseLevel={allResultsCollapsed ? 1 : undefined}
json={resultByIdFiltered}
prefix="results"
on:select
/>
</div>
{/if}
{:else}
{#if pickableProperties.hasResume}
<span class={categoryTitleClasses}>Resume payloads</span>
<div class={categoryContentClasses}>
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
json={{
resume: 'The resume payload',
resumes: 'All resume payloads from all approvers',
approvers: 'The list of approvers'
}}
on:select
/>
</div>
{/if}
{#if Object.keys(pickableProperties.priorIds).length > 0}
{#if suggestedPropsFiltered && Object.keys(suggestedPropsFiltered).length > 0}
<span class={categoryTitleClasses}>Suggested Results</span>
<div class={categoryContentClasses}>
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
collapsed={false}
json={suggestedPropsFiltered}
prefix="results"
on:select
/>
</div>
{/if}
{#if Object.keys(resultByIdFiltered ?? {}).length > 0}
<span class={categoryTitleClasses}>Results</span>
<div class={categoryContentClasses}>
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
collapseLevel={allResultsCollapsed ? 1 : undefined}
json={resultByIdFiltered}
expandedEvenOnLevel0={previousId}
prefix="results"
on:select
/>
</div>
{/if}
{/if}
{/if}
{#if displayContext}
{#if !$inputMatches?.length || $inputMatches?.some((match) => match.word === 'variable')}
<span class={categoryTitleClasses}>Variables</span>
<div class={categoryContentClasses}>
{#if displayVariable}
<Button
size="xs2"
variant="default"
on:click={() => {
displayVariable = false
}}
wrapperClasses="inline-flex whitespace-nowrap w-fit"
btnClasses="font-mono h-4 text-2xs font-thin px-1 rounded-[0.275rem]">-</Button
>
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
rawKey={true}
json={variables}
prefix="variable"
on:select
/>
{:else}
<Button
size="xs2"
variant="default"
on:click={async () => {
await loadVariables()
displayVariable = true
}}
wrapperClasses="inline-flex w-fit"
btnClasses="font-normal text-2xs rounded-[0.275rem] h-4 px-1"
>
{'{...}'}
</Button>
{/if}
</div>
{/if}
{#if !$inputMatches?.length || $inputMatches?.some((match) => match.word === 'resource')}
<span class={categoryTitleClasses}>Resources</span>
<div class={categoryContentClasses}>
{#if displayResources}
<Button
size="xs2"
variant="default"
on:click={() => {
displayResources = false
}}
wrapperClasses="inline-flex whitespace-nowrap w-fit"
btnClasses="font-mono h-4 text-2xs font-thin px-1 rounded-[0.275rem]">-</Button
>
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
rawKey={true}
json={resources}
prefix="resource"
on:select
/>
{:else}
<Button
size="xs2"
variant="default"
on:click={async () => {
await loadResources()
displayResources = true
}}
wrapperClasses="inline-flex whitespace-nowrap w-fit"
btnClasses="font-normal text-2xs rounded-[0.275rem] h-4 px-1"
>
{'{...}'}
</Button>
{/if}
</div>
{/if}
{/if}
<!-- </div> -->
</Scrollable>
</div>