mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 00:01:55 +00:00
fix flow rename (#7978)
* fix(frontend): preserve flow settings when updating summary/path from detail page Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(frontend): type builders prop with ReturnType<typeof createDropdownMenu> Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(frontend): extract shared updateItemPathAndSummary utility to deduplicate move/rename logic Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(frontend): enable inline summary/path editing on script detail page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * improve layout * feat(frontend): add dirty tracking to MoveDrawer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * nit move drawer * fix(frontend): drop on_behalf_of_email from move/rename and warn user about redeployment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(frontend): hide on_behalf_of warning in MoveDrawer when user is not owner Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(frontend): only reload script when path unchanged in onSaved callback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,12 +4,12 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { ChevronRight } from 'lucide-svelte'
|
||||
import type { Item } from '$lib/utils'
|
||||
import type { MenubarMenuElements } from '@melt-ui/svelte'
|
||||
import type { MenubarMenuElements, createDropdownMenu } from '@melt-ui/svelte'
|
||||
import { Tooltip } from './meltComponents'
|
||||
|
||||
interface Props {
|
||||
item: Item
|
||||
builders: any
|
||||
builders: ReturnType<typeof createDropdownMenu>['builders']
|
||||
meltItem: MenubarMenuElements['item']
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { MenubarMenuElements } from '@melt-ui/svelte'
|
||||
import type { MenubarMenuElements, createDropdownMenu } from '@melt-ui/svelte'
|
||||
import type { Item } from '$lib/utils'
|
||||
import { Tooltip } from './meltComponents'
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
aiId?: string
|
||||
items?: Item[] | (() => Item[]) | (() => Promise<Item[]>)
|
||||
meltItem: MenubarMenuElements['item']
|
||||
builders?: any
|
||||
builders?: ReturnType<typeof createDropdownMenu>['builders']
|
||||
}
|
||||
|
||||
let { aiId, items = [], meltItem, builders }: Props = $props()
|
||||
|
||||
@@ -4,21 +4,28 @@
|
||||
import { Alert, Button, Drawer } from './common'
|
||||
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
||||
import Path from './Path.svelte'
|
||||
import { AppService, FlowService, ScriptService } from '$lib/gen'
|
||||
import { isOwner } from '$lib/utils'
|
||||
import { updateItemPathAndSummary, checkFlowOnBehalfOf } from './moveRenameManager'
|
||||
import Label from './Label.svelte'
|
||||
import TextInput from './text_input/TextInput.svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
type Kind = 'script' | 'resource' | 'schedule' | 'variable' | 'flow' | 'app'
|
||||
|
||||
let kind: Kind
|
||||
let initialPath: string = ''
|
||||
let path: string | undefined = undefined
|
||||
let summary: undefined | string = undefined
|
||||
let kind = $state<Kind>('flow')
|
||||
let initialPath = $state('')
|
||||
let initialSummary = $state('')
|
||||
let path = $state<string | undefined>(undefined)
|
||||
let summary = $state<string | undefined>(undefined)
|
||||
let dirtyPath = $state(false)
|
||||
|
||||
let drawer: Drawer
|
||||
let drawer = $state<Drawer>() as Drawer
|
||||
|
||||
let own = $state(false)
|
||||
let onBehalfOfEmail = $state<string | undefined>(undefined)
|
||||
let hasChanges = $derived((summary ?? '') !== initialSummary || dirtyPath)
|
||||
|
||||
let own = false
|
||||
export async function openDrawer(
|
||||
initialPath_l: string,
|
||||
summary_l: string | undefined,
|
||||
@@ -26,10 +33,16 @@
|
||||
) {
|
||||
kind = kind_l
|
||||
path = undefined
|
||||
dirtyPath = false
|
||||
onBehalfOfEmail = undefined
|
||||
initialPath = initialPath_l
|
||||
initialSummary = summary_l ?? ''
|
||||
summary = summary_l
|
||||
loadOwner()
|
||||
drawer.openDrawer()
|
||||
if (kind === 'flow') {
|
||||
onBehalfOfEmail = await checkFlowOnBehalfOf($workspaceStore!, initialPath_l)
|
||||
}
|
||||
}
|
||||
|
||||
function loadOwner() {
|
||||
@@ -37,51 +50,13 @@
|
||||
}
|
||||
|
||||
async function updatePath() {
|
||||
if (kind == 'flow') {
|
||||
const flow = await FlowService.getFlowByPath({
|
||||
if (kind === 'flow' || kind === 'script' || kind === 'app') {
|
||||
await updateItemPathAndSummary({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath
|
||||
})
|
||||
await FlowService.updateFlow({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath,
|
||||
requestBody: {
|
||||
path: path ?? '',
|
||||
summary: summary ?? '',
|
||||
description: flow.description,
|
||||
value: flow.value,
|
||||
schema: flow.schema,
|
||||
tag: flow.tag,
|
||||
dedicated_worker: flow.dedicated_worker,
|
||||
ws_error_handler_muted: flow.ws_error_handler_muted,
|
||||
visible_to_runner_only: flow.visible_to_runner_only,
|
||||
on_behalf_of_email: flow.on_behalf_of_email
|
||||
}
|
||||
})
|
||||
} else if (kind == 'script') {
|
||||
const script = await ScriptService.getScriptByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath
|
||||
})
|
||||
script.summary = summary ?? ''
|
||||
await ScriptService.createScript({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
...script,
|
||||
description: script.description ?? '',
|
||||
lock: script.lock,
|
||||
parent_hash: script.hash,
|
||||
path: path ?? ''
|
||||
}
|
||||
})
|
||||
} else if (kind == 'app') {
|
||||
await AppService.updateApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath,
|
||||
requestBody: {
|
||||
path: path != initialPath ? path : undefined,
|
||||
summary
|
||||
}
|
||||
kind,
|
||||
initialPath,
|
||||
newPath: path ?? '',
|
||||
newSummary: summary ?? ''
|
||||
})
|
||||
}
|
||||
dispatch('update', path)
|
||||
@@ -92,24 +67,33 @@
|
||||
<Drawer bind:this={drawer}>
|
||||
<DrawerContent title="Move/Rename {initialPath}" on:close={drawer.closeDrawer}>
|
||||
{#if !own}
|
||||
<Alert type="warning" title="Not owner">
|
||||
<Alert type="warning" title="Not owner" class="mb-4">
|
||||
Since you do not own this item, you cannot move this item (you can however fork it)
|
||||
</Alert>
|
||||
{/if}
|
||||
<h2 class="border-b pb-1 mt-2 mb-4">Summary</h2>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={summary}
|
||||
placeholder="Short summary to be displayed when listed"
|
||||
disabled={!own}
|
||||
/>
|
||||
{#if own && onBehalfOfEmail}
|
||||
<Alert type="info" title="Run on behalf of" class="mb-4">
|
||||
This flow will be redeployed on behalf of you ({$userStore?.email}) instead of {onBehalfOfEmail}
|
||||
</Alert>
|
||||
{/if}
|
||||
<Label label="Summary" class="mb-6">
|
||||
<TextInput
|
||||
inputProps={{
|
||||
type: 'text',
|
||||
placeholder: 'Short summary to be displayed when listed',
|
||||
disabled: !own
|
||||
}}
|
||||
bind:value={summary}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<h2 class="border-b pb-1 mt-10 mb-4">Path</h2>
|
||||
<div class="flex flex-col mb-2 gap-6">
|
||||
<Path disabled={!own} {kind} {initialPath} bind:path />
|
||||
</div>
|
||||
<Label label="Path">
|
||||
<Path disabled={!own} {kind} {initialPath} bind:path bind:dirty={dirtyPath} />
|
||||
</Label>
|
||||
{#snippet actions()}
|
||||
<Button disabled={!own} on:click={updatePath}>Move/Rename</Button>
|
||||
<Button variant="accent" disabled={!own || !hasChanges} on:click={updatePath}
|
||||
>Move/Rename</Button
|
||||
>
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { emptyString } from '$lib/utils'
|
||||
import { Button } from '$lib/components/common'
|
||||
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, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { updateItemPathAndSummary, checkFlowOnBehalfOf } from './moveRenameManager'
|
||||
import Label from './Label.svelte'
|
||||
|
||||
interface Props {
|
||||
summary?: string
|
||||
path?: string
|
||||
editable?: boolean
|
||||
onEdit?: (summary: string, path: string) => void
|
||||
onSaved?: (newPath: string) => void
|
||||
kind?: 'flow' | 'script'
|
||||
}
|
||||
|
||||
@@ -17,7 +21,7 @@
|
||||
summary = $bindable(''),
|
||||
path = $bindable(''),
|
||||
editable = false,
|
||||
onEdit,
|
||||
onSaved,
|
||||
kind = 'flow'
|
||||
}: Props = $props()
|
||||
|
||||
@@ -25,17 +29,46 @@
|
||||
let editPath = $state('')
|
||||
let dirtyPath = $state(false)
|
||||
let popoverOpen = $state(false)
|
||||
let hasChanges = $derived(editSummary !== (summary ?? '') || dirtyPath)
|
||||
let own = $state(false)
|
||||
let onBehalfOfEmail = $state<string | undefined>(undefined)
|
||||
let hasChanges = $derived(editSummary !== (summary ?? '') || (own && dirtyPath))
|
||||
|
||||
$effect(() => {
|
||||
if (popoverOpen && onEdit) {
|
||||
if (popoverOpen && onSaved) {
|
||||
editSummary = summary ?? ''
|
||||
editPath = path ?? ''
|
||||
own = isOwner(path ?? '', $userStore, $workspaceStore)
|
||||
onBehalfOfEmail = undefined
|
||||
if (kind === 'flow' && $workspaceStore && path) {
|
||||
checkFlowOnBehalfOf($workspaceStore, path).then((email) => {
|
||||
onBehalfOfEmail = email
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function save(close: () => void) {
|
||||
const initialPath = path ?? ''
|
||||
const newPath = own ? editPath : initialPath
|
||||
|
||||
try {
|
||||
await updateItemPathAndSummary({
|
||||
workspace: $workspaceStore!,
|
||||
kind,
|
||||
initialPath,
|
||||
newPath,
|
||||
newSummary: editSummary
|
||||
})
|
||||
sendUserToast(`${kind === 'flow' ? 'Flow' : 'Script'} updated`)
|
||||
close()
|
||||
onSaved?.(newPath)
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Could not update ${kind}: ${e.body ?? e.message}`, true)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if editable || onEdit}
|
||||
{#if editable || onSaved}
|
||||
<Popover
|
||||
placement="bottom-start"
|
||||
contentClasses="p-4"
|
||||
@@ -61,47 +94,51 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet content({ close })}
|
||||
<div class="flex flex-col gap-3 w-[480px]">
|
||||
{#if onEdit}
|
||||
<label class="block text-primary">
|
||||
<div class="pb-1 text-xs font-semibold text-emphasis">Summary</div>
|
||||
<div class="flex flex-col gap-6 w-[480px]">
|
||||
{#if onSaved}
|
||||
<Label label="Summary">
|
||||
<TextInput
|
||||
inputProps={{
|
||||
type: 'text',
|
||||
placeholder: 'Short summary',
|
||||
onkeydown: (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onEdit(editSummary, editPath)
|
||||
close()
|
||||
save(close)
|
||||
}
|
||||
}
|
||||
}}
|
||||
bind:value={editSummary}
|
||||
/>
|
||||
</label>
|
||||
<div class="block text-primary">
|
||||
<div class="pb-1 text-xs font-semibold text-emphasis">Path</div>
|
||||
<Path
|
||||
autofocus={false}
|
||||
bind:path={editPath}
|
||||
bind:dirty={dirtyPath}
|
||||
initialPath={path ?? ''}
|
||||
namePlaceholder={kind}
|
||||
{kind}
|
||||
hideFullPath
|
||||
size="sm"
|
||||
drawerOffset={4000}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
<Label label="Path">
|
||||
{#if own}
|
||||
<Path
|
||||
autofocus={false}
|
||||
bind:path={editPath}
|
||||
bind:dirty={dirtyPath}
|
||||
initialPath={path ?? ''}
|
||||
namePlaceholder={kind}
|
||||
{kind}
|
||||
hideFullPath
|
||||
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={() => {
|
||||
onEdit?.(editSummary, editPath)
|
||||
close()
|
||||
}}
|
||||
onclick={() => save(close)}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
@@ -140,7 +177,7 @@
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else}
|
||||
<div class="min-w-24 truncate flex flex-col">
|
||||
<div class="min-w-24 truncate flex flex-col px-2">
|
||||
{#if !emptyString(summary)}
|
||||
<span class="text-[10px] leading-tight text-tertiary font-mono truncate">{path}</span>
|
||||
{/if}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
errorHandlerKind: 'flow' | 'script'
|
||||
scriptOrFlowPath: string
|
||||
errorHandlerMuted: boolean | undefined
|
||||
onEdit?: (summary: string, path: string) => void
|
||||
onSaved?: (newPath: string) => void
|
||||
children?: import('svelte').Snippet
|
||||
trigger_badges?: import('svelte').Snippet
|
||||
}
|
||||
@@ -49,7 +49,7 @@
|
||||
errorHandlerKind,
|
||||
scriptOrFlowPath,
|
||||
errorHandlerMuted = $bindable(),
|
||||
onEdit,
|
||||
onSaved,
|
||||
children,
|
||||
trigger_badges
|
||||
}: Props = $props()
|
||||
@@ -64,7 +64,7 @@
|
||||
>
|
||||
<div class="grow px-2 inline-flex items-center gap-4 min-w-0">
|
||||
<div class={twMerge($userStore?.operator ? 'pl-10' : '')}>
|
||||
<SummaryPathDisplay {summary} {path} {onEdit} kind={errorHandlerKind} />
|
||||
<SummaryPathDisplay {summary} {path} {onSaved} kind={errorHandlerKind} />
|
||||
</div>
|
||||
{#if tag}
|
||||
<Badge>tag: {tag}</Badge>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { AppService, FlowService, ScriptService } from '$lib/gen'
|
||||
|
||||
type ItemKind = 'flow' | 'script' | 'app'
|
||||
|
||||
/**
|
||||
* Check whether a flow uses on_behalf_of_email.
|
||||
* Callers use this to show a warning before saving.
|
||||
*/
|
||||
export async function checkFlowOnBehalfOf(
|
||||
workspace: string,
|
||||
path: string
|
||||
): Promise<string | undefined> {
|
||||
const flow = await FlowService.getFlowByPath({ workspace, path })
|
||||
return flow.on_behalf_of_email
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared utility that performs the API call to update an item's path and summary.
|
||||
* No toast, no navigation — callers handle those.
|
||||
*
|
||||
* Note: on_behalf_of_email is intentionally omitted from flow updates for security
|
||||
* reasons — the backend will redeploy the flow on behalf of the current user.
|
||||
*/
|
||||
export async function updateItemPathAndSummary(opts: {
|
||||
workspace: string
|
||||
kind: ItemKind
|
||||
initialPath: string
|
||||
newPath: string
|
||||
newSummary: string
|
||||
}): Promise<void> {
|
||||
const { workspace, kind, initialPath, newPath, newSummary } = opts
|
||||
|
||||
if (kind === 'flow') {
|
||||
const flow = await FlowService.getFlowByPath({ workspace, path: initialPath })
|
||||
await FlowService.updateFlow({
|
||||
workspace,
|
||||
path: initialPath,
|
||||
requestBody: {
|
||||
path: newPath,
|
||||
summary: newSummary,
|
||||
description: flow.description,
|
||||
value: flow.value,
|
||||
schema: flow.schema,
|
||||
tag: flow.tag,
|
||||
dedicated_worker: flow.dedicated_worker,
|
||||
ws_error_handler_muted: flow.ws_error_handler_muted,
|
||||
visible_to_runner_only: flow.visible_to_runner_only
|
||||
}
|
||||
})
|
||||
} else if (kind === 'script') {
|
||||
const script = await ScriptService.getScriptByPath({ workspace, path: initialPath })
|
||||
script.summary = newSummary
|
||||
await ScriptService.createScript({
|
||||
workspace,
|
||||
requestBody: {
|
||||
...script,
|
||||
description: script.description ?? '',
|
||||
lock: script.lock,
|
||||
parent_hash: script.hash,
|
||||
path: newPath
|
||||
}
|
||||
})
|
||||
} else if (kind === 'app') {
|
||||
await AppService.updateApp({
|
||||
workspace,
|
||||
path: initialPath,
|
||||
requestBody: {
|
||||
path: newPath !== initialPath ? newPath : undefined,
|
||||
summary: newSummary
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -497,29 +497,12 @@
|
||||
tag={flow?.tag ?? ''}
|
||||
summary={flow?.summary}
|
||||
path={flow?.path}
|
||||
onEdit={can_write
|
||||
? async (newSummary, newPath) => {
|
||||
if (!flow || !$workspaceStore) return
|
||||
try {
|
||||
await FlowService.updateFlow({
|
||||
workspace: $workspaceStore,
|
||||
path: flow.path,
|
||||
requestBody: {
|
||||
path: newPath,
|
||||
summary: newSummary,
|
||||
description: flow.description,
|
||||
value: flow.value,
|
||||
schema: flow.schema
|
||||
}
|
||||
})
|
||||
sendUserToast('Flow updated')
|
||||
if (newPath !== flow.path) {
|
||||
await goto(`/flows/get/${newPath}?workspace=${$workspaceStore}`)
|
||||
} else {
|
||||
loadFlow()
|
||||
}
|
||||
} catch (e) {
|
||||
sendUserToast('Could not update flow: ' + e.body, true)
|
||||
onSaved={can_write
|
||||
? async (newPath) => {
|
||||
if (newPath !== flow?.path) {
|
||||
await goto(`/flows/get/${newPath}?workspace=${$workspaceStore}`)
|
||||
} else {
|
||||
loadFlow()
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
|
||||
@@ -635,6 +635,15 @@
|
||||
}}
|
||||
summary={script?.summary}
|
||||
path={script?.path}
|
||||
onSaved={can_write
|
||||
? async (newPath) => {
|
||||
if (newPath !== script?.path) {
|
||||
await goto(`/scripts/get/${newPath}?workspace=${$workspaceStore}`)
|
||||
} else {
|
||||
loadScript(page.params.hash ?? '')
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{#snippet trigger_badges()}
|
||||
<TriggersBadge
|
||||
|
||||
Reference in New Issue
Block a user