Files
windmill/frontend/src/lib/components/common/table/FlowRow.svelte
T
2026-10-07 18:42:03 +02:00

389 lines
11 KiB
Svelte

<script lang="ts">
import { goto } from '$lib/navigation'
import { base } from '$lib/base'
import Dropdown from '$lib/components/DropdownV2.svelte'
import type MoveDrawer from '$lib/components/MoveDrawer.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import type ShareModal from '$lib/components/ShareModal.svelte'
import { FlowService, type Flow } from '$lib/gen'
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { createEventDispatcher } from 'svelte'
import Badge from '../badge/Badge.svelte'
import Button from '../button/Button.svelte'
import Row from './Row.svelte'
import { selectMenuItems, type RowSelection } from './rowSelection'
import { sendUserToast } from '$lib/toast'
import { copyToClipboard, isOwner } from '$lib/utils'
import { isDeployable } from '$lib/utils_deployable'
import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import {
Pen,
GitFork,
Trash,
List,
FolderOpen,
ChevronUpSquare,
Calendar,
Shield,
Archive,
Copy,
Eye,
HistoryIcon
} from 'lucide-svelte'
import FlowHistory from '$lib/components/flows/FlowHistory.svelte'
import ChatFlowBadge from '$lib/components/flows/ChatFlowBadge.svelte'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import { editInForkAllowed, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork'
import EditInForkButton from './EditInForkButton.svelte'
import { isCloudHosted } from '$lib/cloud'
const operatorBuilderFlows = useOperatorBuilderFlows()
interface Props {
flow: Flow & {
draft_only?: boolean
is_draft?: boolean
draft_path?: string
draft_users?: { username?: string | null }[]
canWrite: boolean
/** Projected from the flow value by the listing; a chat-input flow opens
* as a conversation and is badged as such. */
chat_input_enabled?: boolean
}
marked: string | undefined
shareModal: ShareModal
moveDrawer: MoveDrawer
deleteConfirmedCallback: (() => void) | undefined
deploymentDrawer: DeployWorkspaceDrawer
errorHandlerMuted: boolean
depth?: number
menuOpen?: boolean
showEditButton?: boolean
keyboardSelected?: boolean
rowSelection?: RowSelection
}
let {
flow,
marked,
shareModal,
moveDrawer,
deleteConfirmedCallback = $bindable(),
deploymentDrawer,
errorHandlerMuted,
depth = 0,
menuOpen = $bindable(false),
showEditButton = $bindable(true),
keyboardSelected = false,
rowSelection = undefined
}: Props = $props()
const dispatch = createEventDispatcher()
async function archiveFlow(path: string, archived: boolean): Promise<void> {
try {
await FlowService.archiveFlowByPath({
workspace: $workspaceStore!,
path,
requestBody: { archived }
})
dispatch('change')
sendUserToast(`Archived flow ${path}`)
} catch (err) {
sendUserToast(`Could not archive this flow ${err.body}`, true)
}
}
async function deleteFlow(path: string): Promise<void> {
try {
// Draft-only items have no deployed row to delete — the regular
// route would 404. Route the delete through the syncer so the
// per-user draft row is removed instead.
if (flow.draft_only) {
await UserDraftDbSyncer.save({
workspace: $workspaceStore!,
itemKind: 'flow',
path,
value: null,
immediate: true
})
} else {
await FlowService.deleteFlowByPath({ workspace: $workspaceStore!, path })
}
dispatch('change')
sendUserToast(`Deleted flow ${path}`)
} catch (err) {
sendUserToast(`Could not delete this flow ${err.body}`, true)
}
}
let scheduleEditor: ScheduleEditor | undefined = $state(undefined)
let flowHistory: FlowHistory | undefined = $state(undefined)
let hideForOperator = $derived($userStore?.operator && !$operatorBuilderFlows)
</script>
{#if menuOpen}
<ScheduleEditor onUpdate={() => goto('/schedules')} bind:this={scheduleEditor} />
<FlowHistory bind:this={flowHistory} path={flow.path} />
{/if}
{#snippet chatBadge()}
<ChatFlowBadge />
{/snippet}
<Row
aiId={`flow-row-${flow.path}`}
aiDescription={`Button to access the form to run the flow ${flow.summary ?? flow.path}`}
href={flow.draft_only
? `${base}/flows/edit/${flow.path}`
: `${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`}
kind="flow"
workspaceId={flow.workspace_id ?? $workspaceStore ?? ''}
{keyboardSelected}
{marked}
path={flow.draft_path ?? flow.path}
summary={flow.is_draft ? `${flow.summary || flow.draft_path || flow.path}*` : flow.summary}
{errorHandlerMuted}
canFavorite={!flow.draft_only}
{depth}
{rowSelection}
titleBadge={flow.chat_input_enabled ? chatBadge : undefined}
>
{#snippet tagBadges()}
{#if flow.archived}
<Badge color="red" baseClass="border">archived</Badge>
{/if}
{/snippet}
{#snippet draftBadge()}
<DraftBadge
is_draft={flow.is_draft}
draft_only={flow.draft_only}
draft_users={flow.draft_users}
currentUsername={$userStore?.username}
workspace={$workspaceStore ?? undefined}
itemKind="flow"
path={flow.path}
onMigrated={() => dispatch('change')}
/>
{/snippet}
{#snippet sharedBadge()}
<SharedBadge canWrite={flow.canWrite} extraPerms={flow.extra_perms} />
{/snippet}
{#snippet labelBadges()}
{#if flow.labels?.length}
<div class="flex items-center gap-0.5">
{#each flow.labels.slice(0, 3) as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
{/each}
{#if flow.labels.length > 3}
<Badge
color="blue"
small
class="px-1"
title={flow.labels
.slice(3)
.map((l) => 'Label: ' + l)
.join('\n')}>+{flow.labels.length - 3}</Badge
>
{/if}
</div>
{/if}
<InheritedLabels labels={flow.inherited_labels} />
{/snippet}
{#snippet actions()}
<span class="hidden md:inline-flex gap-x-2">
{#if !hideForOperator && showEditButton && flow.canWrite && !flow.archived}
<div>
<Button
variant="accent"
wrapperClasses="w-16 invisible group-hover/row:visible group-focus-within/row:visible group-data-[row-keyboard-selected=true]/row:visible"
startIcon={{ icon: Pen }}
unifiedSize="sm"
href="{base}/flows/edit/{flow.path}"
aiId={`edit-flow-button-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
aiDescription={`Edits the flow ${flow.summary?.length > 0 ? flow.summary : flow.path}`}
>
Edit
</Button>
</div>
{/if}
<!-- Hidden from every operator, builder rights included. -->
{#if !$userStore?.operator && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !flow.canWrite)}
<EditInForkButton itemType="flow" path={flow.path} />
{/if}
</span>
<Dropdown
size="sm"
fixedHeight={false}
aiId={`flow-row-dropdown-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
aiDescription={`Open dropdown for flow ${flow.summary?.length > 0 ? flow.summary : flow.path} options`}
items={async () => {
let { draft_only, path, archived } = flow
let owner = isOwner(path, $userStore, $workspaceStore)
const canEdit = flow.canWrite && showEditButton
if (draft_only) {
return [
...selectMenuItems(rowSelection),
{
displayName: 'Move/Rename',
icon: FolderOpen,
action: () => {
// Addressed by the generated path its draft row sits at, but
// named by the path typed in the editor.
moveDrawer.openDrawer((flow as any).draft_path ?? path, flow.summary, 'flow', {
storagePath: path
})
},
disabled: !showEditButton,
hide: $userStore?.operator
},
{
displayName: 'Delete',
icon: Trash,
action: (event) => {
// @ts-ignore
if (event?.shiftKey) {
deleteFlow(path)
} else {
deleteConfirmedCallback = () => {
deleteFlow(path)
}
}
},
type: 'delete',
// A draft-only row is always the authed user's own draft (the
// list endpoint only surfaces own/legacy draft-only rows), so
// discarding it never requires write permission on the path.
disabled: !showEditButton,
hide: hideForOperator
}
]
}
return [
...selectMenuItems(rowSelection),
{
displayName: 'View runs',
icon: List,
href: `${base}/runs/${path}`
},
{
displayName: 'Duplicate/Fork',
icon: GitFork,
href: `${base}/flows/add?template=${path}`,
disabled: !showEditButton,
hide: hideForOperator
},
{
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
icon: Pen,
// No `href`: the handler resolves the destination asynchronously, and a melt
// menu item's anchor navigates before a delegated onclick can preventDefault it.
action: (e) => onEditInForkClick(e, 'flow', path),
hide:
$userStore?.operator ||
isCloudHosted() ||
!editInForkAllowed($workspaceStore, $userWorkspaces)
},
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`,
hide: $userStore?.operator
},
{
displayName: 'Move/Rename',
icon: FolderOpen,
action: () => {
moveDrawer.openDrawer(path, flow.summary, 'flow')
},
disabled: !owner || archived || !canEdit,
hide: hideForOperator
},
{
displayName: 'Copy path',
icon: Copy,
action: () => {
copyToClipboard(path)
}
},
...(isDeployable('flow', path, await getDeployUiSettings())
? [
{
displayName: 'Deploy to staging/prod',
icon: ChevronUpSquare,
action: () => {
deploymentDrawer.openDrawer(path, 'flow')
},
disabled: archived,
hide: $userStore?.operator
}
]
: []),
{
displayName: 'Deployments',
icon: HistoryIcon,
action: () => {
flowHistory?.open()
},
hide: hideForOperator
},
{
displayName: 'Schedule',
icon: Calendar,
action: () => {
scheduleEditor?.openNew(true, path)
},
disabled: archived,
hide: hideForOperator
},
{
displayName: 'Permissions',
icon: Shield,
action: () => {
shareModal.openDrawer && shareModal.openDrawer(path, 'flow')
},
hide: hideForOperator
},
{
displayName: archived ? 'Unarchive' : 'Archive',
icon: Archive,
action: () => {
path && archiveFlow(path, !archived)
},
type: 'delete',
disabled: !owner || !canEdit,
hide: hideForOperator
},
{
displayName: 'Delete',
icon: Trash,
action: (event) => {
// @ts-ignore
if (event?.shiftKey) {
deleteFlow(path)
} else {
deleteConfirmedCallback = () => {
deleteFlow(path)
}
}
},
type: 'delete',
disabled: !owner || !canEdit,
hide: hideForOperator
}
]
}}
on:open={() => {
menuOpen = true
}}
/>
{/snippet}
</Row>