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:
Guilhem
2026-02-17 12:48:58 +00:00
committed by GitHub
parent 2d5393941c
commit 3ed86816fb
8 changed files with 213 additions and 127 deletions
@@ -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()
+48 -64
View File
@@ -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