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

359 lines
12 KiB
Svelte

<script lang="ts">
import Toggle from '$lib/components/Toggle.svelte'
import { stepSettingDefaults } from '../flowStepSettings'
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
import type SimpleEditor from '$lib/components/SimpleEditor.svelte'
import { getContext, tick, untrack } from 'svelte'
import { Alert, Button, Tab, Tabs } from '$lib/components/common'
import { GroupService, type FlowModule } from '$lib/gen'
import { emptySchema, emptyString } from '$lib/utils'
import { enterpriseLicense } from '$lib/stores.js'
import { SecondsInput } from '../../common'
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
import type { FlowEditorContext } from '../types'
import Label from '$lib/components/Label.svelte'
import SuspendDrawer from './SuspendDrawer.svelte'
import EditableSchemaDrawer from '$lib/components/schema/EditableSchemaDrawer.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { Pen, Plus } from 'lucide-svelte'
import { slideDynamic } from '$lib/transitions'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
type ApprovalSkin = NonNullable<NonNullable<FlowModule['suspend']>['skin']>
const { selectionManager, flowStateStore, opWorkspace } =
getContext<FlowEditorContext>('FlowEditorContext')
let opWs = $derived(opWorkspace?.() ?? $operatingWorkspace)
const result = flowStateStore.val[selectionManager.getSelectedId()]?.previewResult ?? {}
let editor: SimpleEditor | undefined = $state(undefined)
interface Props {
flowModule: FlowModule
previousModuleId: string | undefined
}
let { flowModule = $bindable(), previousModuleId }: Props = $props()
let schema = $state(emptySchema())
let allUserGroups: string[] = $state([])
let suspendTabSelected: 'core' | 'form' | 'permissions' = $state('core')
let isSuspendEnabled = $derived(Boolean(flowModule.suspend))
async function loadGroups(): Promise<void> {
allUserGroups = await GroupService.listGroupNames({ workspace: opWs! })
schema.properties['groups'] = {
type: 'array',
items: {
type: 'string',
enum: allUserGroups
}
}
}
$effect(() => {
if ($operatingWorkspace && allUserGroups.length === 0) {
untrack(() => {
loadGroups()
})
}
})
$effect(() => {
// If the schema is empty, remove the form
if (Object.keys(flowModule?.suspend?.resume_form?.schema?.properties ?? {}).length === 0) {
untrack(() => {
tick().then(() => {
if (!flowModule.suspend) return
flowModule.suspend.resume_form = undefined
})
})
}
})
let formEditor: EditableSchemaDrawer | undefined = $state(undefined)
// Stands in for the form's schema until the step has one, so the editor can be mounted
// (and thus openable) before the first field exists.
let draftFormSchema = $state(emptySchema())
function openFormEditor() {
if (flowModule.suspend && !flowModule.suspend.resume_form) {
flowModule.suspend.resume_form = { schema: draftFormSchema }
}
formEditor?.openDrawer()
}
function setSkin(skin: ApprovalSkin) {
if (!flowModule.suspend) return
flowModule.suspend.skin = skin === 'detailed' ? undefined : skin
logFeatureUsage('flow_step', 'approval_skin', { key: skin })
}
</script>
<div class="flex w-full flex-col gap-2">
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
checked={isSuspendEnabled}
on:change={() => {
if (isSuspendEnabled && flowModule.suspend != undefined) {
flowModule.suspend = undefined
} else {
flowModule.suspend = stepSettingDefaults('suspend')
}
}}
options={{
right: 'Suspend until approval/resume',
rightTooltip:
'At the end of the step, the flow is suspended until it receives external requests to resume or cancel it. Most useful for approval steps, but can be used flexibly for other purposes.',
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/flow_approval'
}}
/>
{#if isSuspendEnabled}
<div class="flex flex-col gap-3 pl-9" transition:slideDynamic>
<div class="overflow-x-auto scrollbar-hidden">
<Tabs bind:selected={suspendTabSelected}>
<Tab value="core" label="Core" />
<Tab value="form" label="Form" />
<Tab value="permissions" label="Permissions" />
</Tabs>
</div>
{#if suspendTabSelected === 'core'}
<div class="flex flex-col gap-3">
<Label label="Number of approvals/events required for resuming flow">
{#if flowModule.suspend}
<input
bind:value={flowModule.suspend.required_events}
type="number"
min="1"
placeholder="1"
/>
{:else}
<input type="number" disabled />
{/if}
</Label>
<Label label="Timeout">
{#if flowModule.suspend}
<SecondsInput bind:seconds={flowModule.suspend.timeout} />
{:else}
<SecondsInput disabled />
{/if}
</Label>
<Label label="Approval page skin">
<ToggleButtonGroup
noWFull
selected={flowModule.suspend?.skin ?? 'detailed'}
disabled={!flowModule.suspend}
onSelected={setSkin}
>
{#snippet children({ item })}
<ToggleButton
value="detailed"
label="Detailed"
tooltip="The request plus the flow's details: arguments, graph and approvers"
{item}
small
/>
<ToggleButton
value="minimal"
label="Minimal"
tooltip="Only the request: step description, form and approve/reject buttons"
{item}
small
/>
{/snippet}
</ToggleButtonGroup>
<span class="text-2xs font-normal text-secondary">
Slack and Teams approval messages use the same skin
</span>
</Label>
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
options={{
right: 'Continue on disapproval/timeout',
rightTooltip: `Instead of failing the flow and bubbling up the error, continue to the next step which would allow to put a branchone right after to handle both cases separately.
If any disapproval/timeout event is received, the resume payload will be similar to every error result in Windmill, an object containing an "error" field which you can use
to distinguish between approvals and disapproval/timeouts.
We recommend using the expr "resume?.error" to handle null payload values.
To filter timeout, use "resume?.error?.name === "SuspendedTimedOut"
To filter disapproval, use "resume?.error?.name === "SuspendedDisapproved"`
}}
checked={Boolean(flowModule.suspend?.continue_on_disapprove_timeout)}
disabled={!Boolean(flowModule.suspend)}
on:change={(e) => {
if (flowModule.suspend) {
flowModule.suspend.continue_on_disapprove_timeout = e.detail
}
}}
/>
{#if Boolean(flowModule.suspend?.continue_on_disapprove_timeout)}
<Alert type="info" title="Continue on disapproval/timeout">
We recommend using the expr <code>resume?.error</code> to handle null payload values.
<br />
To filter timeout, use <code>resume?.error?.name === "SuspendedTimedOut"</code>.
<br />
To filter disapproval, use <code>resume?.error?.name === "SuspendedDisapproved"</code>
</Alert>
{/if}
</div>
{:else if suspendTabSelected === 'permissions'}
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
eeOnly
disabled={!flowModule.suspend || emptyString($enterpriseLicense)}
checked={Boolean(flowModule.suspend?.user_auth_required)}
options={{
right: 'Require approvers to be logged in'
}}
on:change={(e) => {
if (flowModule.suspend) {
flowModule.suspend.user_auth_required = e.detail
if (e.detail && flowModule.suspend?.user_groups_required === undefined) {
flowModule.suspend.user_groups_required = {
type: 'static',
value: []
}
} else if (!e.detail) {
flowModule.suspend.user_groups_required = undefined
flowModule.suspend.self_approval_disabled = false
}
}
}}
/>
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
eeOnly
options={{
right: 'Disable self-approval',
rightTooltip: 'The user who triggered the flow will not be allowed to approve it'
}}
checked={Boolean(flowModule.suspend?.self_approval_disabled)}
disabled={!flowModule.suspend || !Boolean(flowModule.suspend?.user_auth_required)}
on:change={(e) => {
if (flowModule.suspend) {
flowModule.suspend.self_approval_disabled = e.detail
}
}}
/>
<div class="mb-4"></div>
{#if Boolean(flowModule.suspend?.user_auth_required) && allUserGroups.length !== 0 && flowModule.suspend && schema.properties['groups']}
<span class="text-xs font-bold"
>Require approvers to be members of one of the following user groups (leave empty
for any)
</span>
<PropPickerWrapper
sidePane
notSelectable
{result}
pickableProperties={undefined}
on:select={({ detail }) => {
editor?.insertAtCursor(detail)
editor?.focus()
}}
>
<InputTransformForm
class="min-h-[256px] items-start"
bind:arg={flowModule.suspend.user_groups_required}
argName="groups"
{schema}
{previousModuleId}
bind:editor
/>
</PropPickerWrapper>
{/if}
</div>
</div>
{:else}
<div class="flex flex-col gap-4">
{#if flowModule?.suspend?.resume_form}
<div class="flex justify-end">
<Button
unifiedSize="xs"
variant="default"
startIcon={{ icon: Pen }}
on:click={openFormEditor}
>
Edit form
</Button>
</div>
<!-- The approval page renders the form with the same component; here it only
shows what approvers will see, and editing happens in the drawer. -->
<div class="rounded-md border p-2">
<SchemaForm
schema={flowModule.suspend.resume_form.schema}
disabled
noVariablePicker
/>
</div>
{:else if emptyString($enterpriseLicense)}
<Alert type="warning" title="Adding a form to the approval page is an EE feature" />
{:else}
<Button
unifiedSize="md"
variant="default"
btnClasses="w-full border-dashed"
startIcon={{ icon: Plus }}
on:click={openFormEditor}
>
Add a form
</Button>
{/if}
<EditableSchemaDrawer
bind:this={formEditor}
workspace={opWs}
bind:schema={
() => flowModule.suspend?.resume_form?.schema ?? draftFormSchema,
(v) => {
if (flowModule.suspend) {
flowModule.suspend.resume_form = { schema: v }
}
}
}
drawerOnly
/>
{#if flowModule.suspend?.resume_form}
<Toggle
textClass="text-xs font-normal text-primary"
size="xs"
checked={Boolean(flowModule.suspend.hide_cancel)}
on:change={(e) => {
if (flowModule.suspend) {
flowModule.suspend.hide_cancel = e.detail
}
}}
options={{
right: 'Hide cancel button on approval page'
}}
/>
{/if}
</div>
{/if}
<div class="flex justify-end">
<SuspendDrawer text="Approval/Prompt helpers" />
</div>
</div>
{/if}
</div>