mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-22 00:03:35 +00:00
* 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>
359 lines
12 KiB
Svelte
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>
|