Files
windmill/frontend/src/lib/components/SummaryPathDisplay.svelte
T
Diego Imbert 2b2cdfede5 Merge remote-tracking branch 'origin/enable-operator-ai-chat-nav' into improve-data-pipelines
# Conflicts:
#	frontend/src/lib/components/SummaryPathDisplay.svelte
#	frontend/src/lib/components/sessions/SessionsBetaBanner.svelte
#	frontend/src/routes/(root)/(logged)/+layout.svelte
2026-09-30 11:11:48 +02:00

276 lines
7.6 KiB
Svelte

<script lang="ts">
import { emptyString, isOwner } from '$lib/utils'
import { Alert, Button } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import Path from '$lib/components/Path.svelte'
import { userStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { updateItemPathAndSummary, checkFlowOnBehalfOf } from './moveRenameManager'
import Label from './Label.svelte'
import LabelsInput from './LabelsInput.svelte'
import InheritedLabels from './InheritedLabels.svelte'
import Badge from './common/badge/Badge.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
summary?: string
path?: string
labels?: string[] | undefined
inheritedLabels?: string[] | undefined
editable?: boolean
onSaved?: (newPath: string) => void
kind?: 'flow' | 'script'
/** Saves instead of the API, for an item that exists only locally (a draft).
* Returns an error message to keep the popover open. */
saveOverride?: (next: {
path: string
summary: string
labels: string[] | undefined
}) => string | undefined
/** Header variant: no path line (the host shows it) and a lighter summary. */
compact?: boolean
}
let {
summary = $bindable(''),
path = $bindable(''),
labels = $bindable(),
inheritedLabels = undefined,
editable = false,
onSaved,
kind = 'flow',
saveOverride = undefined,
compact = false
}: Props = $props()
let editSummary = $state('')
let editPath = $state('')
let dirtyPath = $state(false)
let popoverOpen = $state(false)
let ownPath = $state<string | undefined>(undefined)
// Derived: ownership answers about the operating workspace, whose user resolves asynchronously.
const own = $derived(
saveOverride !== undefined ||
(ownPath === undefined ? false : isOwner(ownPath, actingUser, $operatingWorkspace))
)
let onBehalfOfEmail = $state<string | undefined>(undefined)
let summaryInput: ReturnType<typeof TextInput> | undefined = $state()
let labelsDirty = $state(false)
let hasChanges = $derived(editSummary !== (summary ?? '') || (own && dirtyPath) || labelsDirty)
$effect(() => {
if (popoverOpen && onSaved) {
editSummary = summary ?? ''
editPath = path ?? ''
labelsDirty = false
ownPath = path ?? ''
onBehalfOfEmail = undefined
if (kind === 'flow' && $operatingWorkspace && path) {
checkFlowOnBehalfOf($operatingWorkspace, path).then((email) => {
onBehalfOfEmail = email
})
}
}
})
async function save(close: () => void) {
const initialPath = path ?? ''
const newPath = own ? editPath : initialPath
if (saveOverride) {
const error = saveOverride({ path: newPath, summary: editSummary, labels })
if (error) {
sendUserToast(error, true)
return
}
labelsDirty = false
close()
onSaved?.(newPath)
return
}
try {
await updateItemPathAndSummary({
workspace: $operatingWorkspace!,
kind,
initialPath,
newPath,
newSummary: editSummary,
labels
})
sendUserToast(`${kind === 'flow' ? 'Flow' : 'Script'} updated`)
labelsDirty = false
close()
onSaved?.(newPath)
} catch (e: any) {
sendUserToast(`Could not update ${kind}: ${e.body ?? e.message}`, true)
}
}
</script>
{#if editable || onSaved}
<Popover
class="min-w-0 max-w-full"
placement="bottom-start"
contentClasses="p-4"
usePointerDownOutside
excludeSelectors=".drawer"
disableFocusTrap
openFocus={() => {
summaryInput?.focus()
return null
}}
bind:isOpen={popoverOpen}
>
{#snippet trigger()}
<div
class="min-w-0 truncate flex rounded-md transition-colors cursor-pointer hover:bg-surface-hover {compact
? 'items-center px-1 py-0.5'
: 'flex-col items-start px-2 py-1'}"
>
{#if !compact}
<span
class="text-2xs leading-tight text-tertiary font-mono font-normal truncate max-w-full"
>{path}</span
>
{/if}
<div class="flex items-center gap-3 max-w-full">
<span
class="{compact ? 'text-xs font-medium' : 'text-sm font-semibold'} truncate {emptyString(
summary
)
? 'text-tertiary italic font-normal'
: 'text-emphasis'}"
>
{emptyString(summary) ? 'Add a summary...' : summary}
</span>
{#if labels?.length}
<div class="flex items-center gap-0.5">
{#each labels as label}
<Badge color="blue" verySmall class="px-1" title="Label: {label}">{label}</Badge>
{/each}
</div>
{/if}
<InheritedLabels labels={inheritedLabels} />
</div>
</div>
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col gap-6 w-[480px]">
{#if onSaved}
<Label label="Summary">
<TextInput
bind:this={summaryInput}
inputProps={{
type: 'text',
placeholder: 'Short summary',
onkeydown: (e) => {
if (e.key === 'Enter') {
save(close)
}
}
}}
bind:value={editSummary}
/>
</Label>
<div class="-mt-4 flex items-center gap-2">
<LabelsInput
bind:labels
onchange={() => {
labelsDirty = true
}}
/>
{#if inheritedLabels?.length}
<InheritedLabels labels={inheritedLabels} />
{/if}
</div>
{#if inheritedLabels?.length}
<p class="-mt-5 text-2xs text-tertiary">
Gray labels are inherited from the folder and can only be edited there.
</p>
{/if}
<Label label="Path">
{#if own}
<Path
autofocus={false}
bind:path={editPath}
bind:dirty={dirtyPath}
initialPath={path ?? ''}
namePlaceholder={kind}
{kind}
size="sm"
drawerOffset={4000}
/>
{:else}
<span class="text-xs font-mono text-secondary">{path}</span>
<p class="text-2xs text-tertiary mt-1">Only the owner can change the path</p>
{/if}
</Label>
{#if onBehalfOfEmail}
<Alert type="info" title="Run on behalf of" size="xs">
This flow will be redeployed on behalf of you ({$userStore?.email}) instead of {onBehalfOfEmail}
</Alert>
{/if}
<Button
size="xs"
variant="accent"
disabled={!hasChanges}
title="Save summary and path"
onclick={() => save(close)}
>
Save
</Button>
{:else}
<label class="block text-primary">
<div class="pb-1 text-xs font-semibold text-emphasis">Summary</div>
<TextInput
bind:this={summaryInput}
inputProps={{
type: 'text',
placeholder: 'Short summary',
onkeydown: (e) => {
if (e.key === 'Enter') {
close()
}
}
}}
bind:value={summary}
/>
</label>
<div class="block text-primary">
<div class="pb-1 text-xs font-semibold text-emphasis">Path</div>
<Path
autofocus={false}
bind:path
bind:dirty={dirtyPath}
initialPath={path ?? ''}
namePlaceholder={kind}
{kind}
size="sm"
drawerOffset={4000}
/>
</div>
{/if}
</div>
{/snippet}
</Popover>
{:else}
<div class="min-w-0 truncate flex items-center {compact ? '' : 'flex-col px-2'}">
{#if !emptyString(summary) && !compact}
<span class="text-[10px] leading-tight text-tertiary font-mono truncate">{path}</span>
{/if}
<span
class="{compact ? 'text-xs font-medium' : 'text-sm font-semibold'} text-emphasis truncate"
>
{emptyString(summary) ? (path ?? '') : summary}
</span>
</div>
{/if}