feat(frontend): inline edit summary & path from header (#7968)

* allow editing flow/script summary

* feat(frontend): wire up edit summary/path on flow detail page

- Fix on:click → onclick (Svelte 5) and add title on Save button
- Make can_write reactive ($state) so onEdit prop updates correctly
- Wire onEdit in flow detail page to call FlowService.updateFlow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(frontend): use Path component for path editing in detail page header

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(frontend): extract SummaryPathDisplay component with edit popover

Consolidate the summary+path display and edit popover into a reusable
SummaryPathDisplay component, used in both the detail page header and
the flow editor toolbar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(frontend): add size prop to Path/FolderPicker, compact popover

Add size prop ('sm' | 'md') to Path and FolderPicker components,
passed through to ToggleButton, TextInput, and Button children.
Use hideFullPath and size="sm" in the SummaryPathDisplay popover
for a compact inline path editor. Widen popover to 480px.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix add folder in path editor

* fix(frontend): disable focus trap on edit popover for drawer access

Disable melt-ui's focus trap on the SummaryPathDisplay popover so
that inputs inside drawers (e.g. New Folder) can receive focus.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* nit

* feat(frontend): auto-create folder and render drawer above popover

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(frontend): show placeholder and hover-reveal pencil in SummaryPathDisplay

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(frontend): click-to-edit SummaryPathDisplay with inline layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(frontend): move undo/redo and tutorials into dropdown submenu with notification dot

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(frontend): stack path above summary in SummaryPathDisplay

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(frontend): bind summary/path directly in flow builder popover

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* nit

* chore: add PR screenshots (to be removed before merge)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: remove PR screenshots (moved to release assets)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-02-17 00:10:05 +00:00
committed by GitHub
parent 5e02332507
commit 67ef376c8f
11 changed files with 541 additions and 199 deletions

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
import { melt } from '@melt-ui/svelte'
import { twMerge } from 'tailwind-merge'
import { ChevronRight } from 'lucide-svelte'
import type { Item } from '$lib/utils'
import type { MenubarMenuElements } from '@melt-ui/svelte'
import { Tooltip } from './meltComponents'
interface Props {
item: Item
builders: any
meltItem: MenubarMenuElements['item']
}
let { item, builders, meltItem }: Props = $props()
const {
elements: { subTrigger, subMenu },
states: { subOpen }
} = builders.createSubmenu()
let subItems = $derived((item.submenuItems ?? []).filter((i) => !i.hide))
</script>
<button
use:melt={$subTrigger}
class={twMerge(
'px-4 py-2 text-primary font-normal hover:bg-surface-hover cursor-pointer text-xs transition-colors w-full',
'data-[highlighted]:bg-surface-hover',
'flex flex-row gap-2 items-center rounded-sm'
)}
>
{#if item.icon}
<item.icon size={14} color={item.iconColor} class="shrink-0" />
{/if}
<p class="truncate grow min-w-0 whitespace-nowrap text-left">
{item.displayName}
</p>
{@render item.extra?.()}
<ChevronRight size={14} class="ml-auto shrink-0 text-tertiary" />
</button>
{#if $subOpen}
<div
use:melt={$subMenu}
class="z-[6000] bg-surface-tertiary dark:border w-48 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1"
>
{#each subItems as subItem}
{#if subItem.separatorTop}
<div class="my-1 border-t border-border-light"></div>
{/if}
<MenuItem
onClick={(e) => subItem?.action?.(e)}
href={subItem?.href}
target={subItem?.hrefTarget}
disabled={subItem?.disabled}
class={twMerge(
'px-4 py-2 text-primary font-normal hover:bg-surface-hover cursor-pointer text-xs transition-colors w-full',
'data-[highlighted]:bg-surface-hover',
'flex flex-row gap-2 items-center rounded-sm',
subItem?.disabled && 'text-disabled cursor-not-allowed'
)}
item={meltItem}
>
{#if subItem.icon}
<subItem.icon size={14} color={subItem.iconColor} class="shrink-0" />
{/if}
<p title={subItem.displayName} class="truncate grow min-w-0 whitespace-nowrap text-left">
{subItem.displayName}
</p>
{@render subItem.extra?.()}
{#if subItem.tooltip}
<Tooltip>
{#snippet text()}
{subItem.tooltip}
{/snippet}
</Tooltip>
{/if}
</MenuItem>
{/each}
</div>
{/if}

View File

@@ -71,6 +71,7 @@
const {
elements: { menu: menuEl, item, trigger },
builders,
states,
ids: { menu: dropdownId }
} = createDropdownMenu({
@@ -177,7 +178,7 @@
class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1"
style={`${customWidth ? `width: ${customWidth}px;` : ''} max-height: ${maxHeight || '50vh'};`}
>
<DropdownV2Inner {aiId} items={computeItems} meltItem={item} />
<DropdownV2Inner {aiId} items={computeItems} meltItem={item} {builders} />
</div>
{/if}
</div>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte'
import { Loader2 } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import type { MenubarMenuElements } from '@melt-ui/svelte'
@@ -10,9 +11,10 @@
aiId?: string
items?: Item[] | (() => Item[]) | (() => Promise<Item[]>)
meltItem: MenubarMenuElements['item']
builders?: any
}
let { aiId, items = [], meltItem }: Props = $props()
let { aiId, items = [], meltItem, builders }: Props = $props()
let computedItems: Item[] | undefined = $state(undefined)
async function computeItems() {
@@ -29,39 +31,46 @@
{#if computedItems}
<div class="flex flex-col">
{#each computedItems ?? [] as item}
<MenuItem
onClick={(e) => item?.action?.(e)}
href={item?.href}
target={item?.hrefTarget}
disabled={item?.disabled}
class={twMerge(
'px-4 py-2 text-primary font-normal hover:bg-surface-hover cursor-pointer text-xs transition-colors w-full',
'data-[highlighted]:bg-surface-hover',
'flex flex-row gap-2 items-center rounded-sm',
item?.disabled && 'text-disabled cursor-not-allowed',
item?.type === 'delete' &&
!item?.disabled &&
'text-red-600 dark:text-red-400 data-[highlighted]:bg-red-500/10 dark:data-[highlighted]:bg-red-900/80 dark:data-[highlighted]:text-red-300 '
)}
item={meltItem}
aiId={`${aiId ? `${aiId}-${item.displayName}` : undefined}`}
aiDescription={item.displayName}
>
{#if item.icon}
<item.icon size={14} color={item.iconColor} class="shrink-0" />
{/if}
<p title={item.displayName} class="truncate grow min-w-0 whitespace-nowrap text-left">
{item.displayName}
</p>
{@render item.extra?.()}
{#if item.tooltip}
<Tooltip>
{#snippet text()}
{item.tooltip}
{/snippet}
</Tooltip>
{/if}
</MenuItem>
{#if item.separatorTop}
<div class="my-1 border-t border-border-light"></div>
{/if}
{#if item.submenuItems && builders}
<DropdownSubmenuItem {item} {builders} {meltItem} />
{:else}
<MenuItem
onClick={(e) => item?.action?.(e)}
href={item?.href}
target={item?.hrefTarget}
disabled={item?.disabled}
class={twMerge(
'px-4 py-2 text-primary font-normal hover:bg-surface-hover cursor-pointer text-xs transition-colors w-full',
'data-[highlighted]:bg-surface-hover',
'flex flex-row gap-2 items-center rounded-sm',
item?.disabled && 'text-disabled cursor-not-allowed',
item?.type === 'delete' &&
!item?.disabled &&
'text-red-600 dark:text-red-400 data-[highlighted]:bg-red-500/10 dark:data-[highlighted]:bg-red-900/80 dark:data-[highlighted]:text-red-300 '
)}
item={meltItem}
aiId={`${aiId ? `${aiId}-${item.displayName}` : undefined}`}
aiDescription={item.displayName}
>
{#if item.icon}
<item.icon size={14} color={item.iconColor} class="shrink-0" />
{/if}
<p title={item.displayName} class="truncate grow min-w-0 whitespace-nowrap text-left">
{item.displayName}
</p>
{@render item.extra?.()}
{#if item.tooltip}
<Tooltip>
{#snippet text()}
{item.tooltip}
{/snippet}
</Tooltip>
{/if}
</MenuItem>
{/if}
{/each}
</div>
{:else}

View File

@@ -19,6 +19,8 @@
orderedJsonStringify,
readFieldsRecursively,
replaceFalseWithUndefined,
isMac,
type Item,
type StateStore,
type Value
} from '$lib/utils'
@@ -27,10 +29,10 @@
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte'
import { onMount, setContext, untrack, type ComponentType } from 'svelte'
import { createRawSnippet, onMount, setContext, untrack } from 'svelte'
import { writable } from 'svelte/store'
import CenteredPage from './CenteredPage.svelte'
import { Badge, Button, UndoRedo } from './common'
import { Button } from './common'
import FlowEditor from './flows/FlowEditor.svelte'
import ScriptEditorDrawer from './flows/content/ScriptEditorDrawer.svelte'
import FlowEditorDrawer from './flows/content/FlowEditorDrawer.svelte'
@@ -44,13 +46,18 @@
import { cleanFlow } from './flows/utils.svelte'
import {
Calendar,
Pen,
Save,
DiffIcon,
HistoryIcon,
FileJson,
type Icon,
Settings
Settings,
Undo,
Redo,
BookOpen,
Circle,
CheckCircle,
RefreshCw,
CheckCheck
} from 'lucide-svelte'
import Awareness from './Awareness.svelte'
import { getAllModules } from './flows/flowExplorer'
@@ -59,8 +66,10 @@
import Dropdown from '$lib/components/DropdownV2.svelte'
import FlowTutorials from './FlowTutorials.svelte'
import FlowHistory from './flows/FlowHistory.svelte'
import FlowEditorTutorial from './flows/FlowEditorTutorial.svelte'
import Summary from './Summary.svelte'
import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils'
import { tutorialsToDo } from '$lib/stores'
import { getTutorialIndex } from '$lib/tutorials/config'
import SummaryPathDisplay from './SummaryPathDisplay.svelte'
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
import FlowYamlEditor from './flows/header/FlowYamlEditor.svelte'
import { type TriggerContext, type ScheduleTrigger } from './triggers'
@@ -86,7 +95,6 @@
import type { FlowBuilderProps } from './flow_builder'
import { ModulesTestStates } from './modulesTest.svelte'
import FlowAssetsHandler, { initFlowGraphAssetsCtx } from './flows/FlowAssetsHandler.svelte'
import { inputSizeClasses } from './text_input/TextInput.svelte'
let {
initialPath = $bindable(''),
@@ -689,6 +697,29 @@
}
}
function handleUndo() {
const currentModules = flowStore.val?.value?.modules
flowStore.val = undo(history, flowStore.val)
const newModules = flowStore.val?.value?.modules
const restoredModules = newModules?.filter(
(node) => !currentModules?.some((currentNode) => currentNode?.id === node?.id)
)
for (const mod of restoredModules) {
if (mod) {
try {
loadFlowModuleState(mod).then((state) => (flowStateStore.val[mod.id] = state))
} catch (e) {
console.error('Error loading state for restored node', e)
}
}
}
selectionManager.selectId('Input')
}
function handleRedo() {
flowStore.val = redo(history)
}
function onKeyDown(event: KeyboardEvent) {
let classes = event.target?.['className']
if (
@@ -701,14 +732,13 @@
switch (event.key) {
case 'Z':
if (event.ctrlKey || event.metaKey) {
flowStore.val = redo(history)
handleRedo()
event.preventDefault()
}
break
case 'z':
if (event.ctrlKey || event.metaKey) {
flowStore.val = undo(history, flowStore.val)
selectionManager.selectId('Input')
handleUndo()
event.preventDefault()
}
break
@@ -804,18 +834,94 @@
}
}
let moreItems: {
displayName: string
icon: ComponentType<Icon>
action: () => void
disabled?: boolean
}[] = $state([])
let baseMenuItems: Item[] = $state([])
const mod = isMac() ? '⌘' : 'Ctrl+'
const undoShortcutSnippet = createRawSnippet(() => ({
render: () => `<span class="ml-auto text-2xs text-tertiary">${mod}Z</span>`
}))
const redoShortcutSnippet = createRawSnippet(() => ({
render: () => `<span class="ml-auto text-2xs text-tertiary">${mod}⇧Z</span>`
}))
function getMoreItems(): Item[] {
return [
...baseMenuItems,
{
displayName: 'Undo',
icon: Undo,
action: () => handleUndo(),
disabled: $history.index === 0,
extra: undoShortcutSnippet,
separatorTop: baseMenuItems.length > 0
},
{
displayName: 'Redo',
icon: Redo,
action: () => handleRedo(),
disabled: $history.index === $history.history.length - 1,
extra: redoShortcutSnippet
},
{
displayName: 'Tutorials',
icon: BookOpen,
separatorTop: true,
extra: (() => {
const remaining = [
getTutorialIndex('flow-live-tutorial'),
getTutorialIndex('troubleshoot-flow')
].filter((i) => $tutorialsToDo.includes(i)).length
return remaining > 0
? createRawSnippet(() => ({
render: () =>
`<span class="ml-auto inline-flex items-center justify-center w-4 h-4 text-[10px] font-medium text-white rounded-full bg-surface-accent-primary">${remaining}</span>`
}))
: undefined
})(),
submenuItems: [
{
displayName: 'Build a flow',
action: () => flowTutorials?.runTutorialById('flow-live-tutorial'),
icon: $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial'))
? Circle
: CheckCircle,
iconColor: $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial'))
? undefined
: 'green'
},
{
displayName: 'Fix a broken flow',
action: () => flowTutorials?.runTutorialById('troubleshoot-flow'),
icon: $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow'))
? Circle
: CheckCircle,
iconColor: $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow'))
? undefined
: 'green'
},
{
displayName: 'Reset tutorials',
action: () => resetAllTodos(),
icon: RefreshCw,
separatorTop: true
},
{
displayName: 'Skip tutorials',
action: () => skipAllTodos(),
icon: CheckCheck
}
]
}
]
}
function onCustomUiChange(
customUi: FlowBuilderWhitelabelCustomUi | undefined,
hasAiDiff: boolean
) {
moreItems = [
baseMenuItems = [
...(customUi?.topBar?.history != false
? [
{
@@ -1007,42 +1113,14 @@
<div class="flex flex-col flex-1 h-screen">
<!-- Nav between steps-->
<div
class="justify-between flex flex-row items-center pl-2.5 pr-6 space-x-4 scrollbar-hidden overflow-x-auto max-h-12 h-full relative"
class="justify-between flex flex-row items-center pl-2 pr-4 space-x-4 scrollbar-hidden overflow-x-auto max-h-12 h-full relative"
>
<div class="flex w-full max-w-md gap-4 items-center">
<Summary
disabled={customUi?.topBar?.editableSummary == false}
bind:value={flowStore.val.summary}
/>
<UndoRedo
undoProps={{ disabled: $history.index === 0 }}
redoProps={{ disabled: $history.index === $history.history.length - 1 }}
on:undo={() => {
const currentModules = flowStore.val?.value?.modules
// console.log('undo before', flowStore.val, JSON.stringify(flowStore.val, null, 2))
flowStore.val = undo(history, flowStore.val)
// console.log('undo after', flowStore.val, JSON.stringify(flowStore.val, null, 2))
const newModules = flowStore.val?.value?.modules
const restoredModules = newModules?.filter(
(node) => !currentModules?.some((currentNode) => currentNode?.id === node?.id)
)
for (const mod of restoredModules) {
if (mod) {
try {
loadFlowModuleState(mod).then((state) => (flowStateStore.val[mod.id] = state))
} catch (e) {
console.error('Error loading state for restored node', e)
}
}
}
selectionManager.selectId('Input')
}}
on:redo={() => {
flowStore.val = redo(history)
}}
<div class="flex w-full max-w-md gap-8 items-center">
<SummaryPathDisplay
bind:summary={flowStore.val.summary}
bind:path={$pathStore}
kind="flow"
editable
/>
</div>
@@ -1068,44 +1146,19 @@
''}
</Button>
{/if}
{#if customUi?.topBar?.path != false}
<div class="flex justify-start items-center w-full">
<button
onclick={async () => {
select('settings-metadata')
document.getElementById('path')?.focus()
}}
>
<Badge
color="gray"
class="text-primary rounded-r-none border border-r-0 {inputSizeClasses.md}"
>
<Pen size={12} class="mr-2" /> Path
</Badge>
</button>
<input
type="text"
readonly
value={$pathStore && $pathStore != '' ? $pathStore : 'Choose a path'}
class="font-mono !text-2xs !min-w-[96px] !max-w-[300px] !w-full !h-[28px] !my-0 !py-0 !border-l-0 cursor-default !rounded-l-none {inputSizeClasses.md}"
onfocus={({ currentTarget }) => {
currentTarget.select()
}}
/>
</div>
{/if}
</div>
<div class="flex flex-row gap-2 items-center">
{#if $enterpriseLicense && !newFlow}
<Awareness />
{/if}
<div>
{#if moreItems?.length > 0}
<Dropdown items={moreItems} />
<div class="relative">
<Dropdown items={getMoreItems} />
{#if $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial')) || $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow'))}
<span
class="absolute top-0.5 right-0.5 block w-2 h-2 rounded-full bg-surface-accent-primary pointer-events-none"
></span>
{/if}
</div>
<FlowEditorTutorial />
{#if customUi?.topBar?.diff != false}
<Button
variant="default"

View File

@@ -1,9 +1,10 @@
<script lang="ts">
import { FolderService } from '$lib/gen'
import { workspaceStore, userStore } from '$lib/stores'
import { Plus, Eye } from 'lucide-svelte'
import { Eye } from 'lucide-svelte'
import { Button, Drawer, DrawerContent } from './common'
import FolderEditor from './FolderEditor.svelte'
import Select from './select/Select.svelte'
let folders: { name: string; write: boolean }[] = $state([])
let newFolder: Drawer | null = $state(null)
@@ -16,6 +17,8 @@
initialPath?: string
disabled?: boolean
disableEditing?: boolean
size?: 'sm' | 'md'
drawerOffset?: number
}
let {
@@ -23,6 +26,8 @@
initialPath = $bindable(undefined),
disabled = $bindable(undefined),
disableEditing = $bindable(undefined),
size = 'md',
drawerOffset = 0
}: Props = $props()
async function loadFolders(): Promise<void> {
@@ -52,6 +57,12 @@
)
}
async function openCreateFolder(name: string) {
newFolderName = name
await addFolder()
newFolder?.openDrawer()
}
async function addFolder() {
await FolderService.createFolder({
workspace: $workspaceStore ?? '',
@@ -63,61 +74,54 @@
loadFolders()
}
let selectItems = $derived(
folders.map((f) => ({
value: f.name,
label: f.name + (f.write ? '' : ' (read-only)'),
disabled: !f.write
}))
)
loadFolders()
</script>
<Drawer bind:this={newFolder} name="newFolder">
<Drawer bind:this={newFolder} name="newFolder" offset={drawerOffset}>
<DrawerContent
title="New Folder"
title="Folder {folderCreated ?? ''}"
on:close={() => {
newFolder?.closeDrawer()
folderCreated = undefined
}}
>
{#if !folderCreated}
<div class="flex flex-col gap-2">
<input placeholder="New folder name" bind:value={newFolderName} />
<Button size="md" startIcon={{ icon: Plus }} disabled={!newFolderName} on:click={addFolder}>
New&nbsp;folder
</Button>
</div>
{:else}
{#if folderCreated}
<FolderEditor name={folderCreated} />
{/if}
</DrawerContent>
</Drawer>
<Drawer bind:this={viewFolder}>
<Drawer bind:this={viewFolder} offset={drawerOffset}>
<DrawerContent title="Folder {folderName}" on:close={viewFolder.closeDrawer}>
<FolderEditor name={folderName ?? ''} />
</DrawerContent>
</Drawer>
<div class="flex flex-row items-center gap-1 w-full">
<select class="grow w-full" disabled={disabled || disableEditing} bind:value={folderName}>
{#if folders?.length == 0}
<option disabled>No folders</option>
{/if}
{#each folders as { name, write }}
<option disabled={!write}>{name}{write ? '' : ' (read-only)'}</option>
{/each}
</select>
<Select
bind:value={folderName}
items={selectItems}
disabled={disabled || disableEditing}
{size}
placeholder="Select folder"
createText="Create folder"
onCreateItem={openCreateFolder}
/>
<Button
title="View folder"
variant="default"
unifiedSize="md"
variant="subtle"
unifiedSize={size}
disabled={!folderName || folderName == ''}
on:click={viewFolder.openDrawer}
iconOnly
startIcon={{ icon: Eye }}
/>
<Button
title="New folder"
variant="default"
unifiedSize="md"
{disabled}
on:click={newFolder.openDrawer}
iconOnly
startIcon={{ icon: Plus }}
/>
</div>

View File

@@ -69,6 +69,9 @@
kind: PathKind
hideUser?: boolean
disableEditing?: boolean
hideFullPath?: boolean
size?: 'sm' | 'md'
drawerOffset?: number
}
let {
@@ -83,7 +86,10 @@
dirty = $bindable(false),
kind,
hideUser = false,
disableEditing = false
disableEditing = false,
hideFullPath = false,
size = 'md',
drawerOffset = 0
}: Props = $props()
$effect.pre(() => {
@@ -394,7 +400,7 @@
</script>
<div>
<div class="flex flex-col flex-wrap sm:flex-row sm:items-center gap-2 pb-0 mb-1">
<div class="flex gap-2 pb-0 mb-1 flex-col flex-wrap sm:flex-row sm:items-center">
{#if meta != undefined}
<!-- svelte-ignore a11y_label_has_associated_control -->
{#if !hideUser}
@@ -423,6 +429,7 @@
disabled={disabled || disableEditing}
value="user"
label="User"
{size}
{item}
/>
<!-- <ToggleButton light size="xs" value="group" position="center">Group</ToggleButton> -->
@@ -431,6 +438,7 @@
disabled={disabled || disableEditing}
value="folder"
label="Folder"
{size}
{item}
/>
{/snippet}
@@ -445,6 +453,7 @@
<label class="block shrink min-w-0">
<TextInput
class="!w-36"
{size}
bind:value={meta.owner}
inputProps={{
type: 'text',
@@ -456,8 +465,15 @@
/>
</label>
{:else if meta.ownerKind === 'folder'}
<label class="block grow w-48">
<FolderPicker bind:folderName={meta.owner} {initialPath} {disabled} {disableEditing} />
<label class="block grow w-42">
<FolderPicker
bind:folderName={meta.owner}
{initialPath}
{disabled}
{disableEditing}
{size}
{drawerOffset}
/>
</label>
{/if}
</div>
@@ -467,6 +483,7 @@
<TextInput
bind:this={inputP}
bind:value={meta.name}
{size}
{error}
inputProps={{
disabled: disabled || disableEditing,
@@ -482,28 +499,30 @@
{/if}
</div>
<div class="flex flex-col w-full mt-2">
<div class="flex justify-start w-full">
<Badge
color="gray"
class="center-center !bg-surface-secondary !text-primary !w-[70px] !h-[24px] rounded-r-none border"
>
Full path
</Badge>
<input
type="text"
readonly
value={path}
size={path?.length || 50}
class="font-mono !text-xs max-w-[calc(100%-70px)] !w-auto !h-[24px] !py-0 !border-l-0 !rounded-l-none"
onfocus={({ currentTarget }) => {
currentTarget.select()
}}
/>
<!-- <span class="font-mono text-sm break-all">{path}</span> -->
{#if !hideFullPath}
<div class="flex flex-col w-full mt-2">
<div class="flex justify-start w-full">
<Badge
color="gray"
class="center-center !bg-surface-secondary !text-primary !w-[70px] !h-[24px] rounded-r-none border"
>
Full path
</Badge>
<input
type="text"
readonly
value={path}
size={path?.length || 50}
class="font-mono !text-xs max-w-[calc(100%-70px)] !w-auto !h-[24px] !py-0 !border-l-0 !rounded-l-none"
onfocus={({ currentTarget }) => {
currentTarget.select()
}}
/>
<!-- <span class="font-mono text-sm break-all">{path}</span> -->
</div>
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5">{error}</div>
</div>
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5">{error}</div>
</div>
{/if}
{#if pathUsageInFlowsPromise || pathUsageInAppsPromise || pathUsageInScriptsPromise}
{#await Promise.all( [pathUsageInAppsPromise, pathUsageInFlowsPromise, pathUsageInScriptsPromise] )}

View File

@@ -0,0 +1,151 @@
<script lang="ts">
import { emptyString } from '$lib/utils'
import { 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'
interface Props {
summary?: string
path?: string
editable?: boolean
onEdit?: (summary: string, path: string) => void
kind?: 'flow' | 'script'
}
let {
summary = $bindable(''),
path = $bindable(''),
editable = false,
onEdit,
kind = 'flow'
}: Props = $props()
let editSummary = $state('')
let editPath = $state('')
let dirtyPath = $state(false)
let popoverOpen = $state(false)
let hasChanges = $derived(editSummary !== (summary ?? '') || dirtyPath)
$effect(() => {
if (popoverOpen && onEdit) {
editSummary = summary ?? ''
editPath = path ?? ''
}
})
</script>
{#if editable || onEdit}
<Popover
placement="bottom-start"
contentClasses="p-4"
usePointerDownOutside
excludeSelectors=".drawer"
disableFocusTrap
bind:isOpen={popoverOpen}
>
{#snippet trigger()}
<div
class={'min-w-24 truncate flex flex-col items-start px-2 py-1 rounded-md transition-colors cursor-pointer hover:bg-surface-hover'}
>
<span class="text-2xs leading-tight text-tertiary font-mono font-normal truncate max-w-full"
>{path}</span
>
<span
class="text-sm font-semibold truncate max-w-full {emptyString(summary)
? 'text-tertiary italic font-normal'
: 'text-emphasis'}"
>
{emptyString(summary) ? 'Add a summary...' : summary}
</span>
</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>
<TextInput
inputProps={{
type: 'text',
placeholder: 'Short summary',
onkeydown: (e) => {
if (e.key === 'Enter') {
onEdit(editSummary, editPath)
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>
<Button
size="xs"
variant="accent"
disabled={!hasChanges}
title="Save summary and path"
onclick={() => {
onEdit?.(editSummary, editPath)
close()
}}
>
Save
</Button>
{:else}
<label class="block text-primary">
<div class="pb-1 text-xs font-semibold text-emphasis">Summary</div>
<TextInput
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}
hideFullPath
size="sm"
drawerOffset={4000}
/>
</div>
{/if}
</div>
{/snippet}
</Popover>
{:else}
<div class="min-w-24 truncate flex flex-col">
{#if !emptyString(summary)}
<span class="text-[10px] leading-tight text-tertiary font-mono truncate">{path}</span>
{/if}
<span class="text-sm font-semibold text-emphasis truncate">
{emptyString(summary) ? (path ?? '') : summary}
</span>
</div>
{/if}

View File

@@ -6,9 +6,9 @@
import { twMerge } from 'tailwind-merge'
import { userStore } from '$lib/stores'
import { createEventDispatcher, getContext, tick } from 'svelte'
import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte'
import type { TriggerContext } from '../triggers'
import { Calendar } from 'lucide-svelte'
import { emptyString } from '$lib/utils'
type MainButton = {
label: string
@@ -35,6 +35,7 @@
errorHandlerKind: 'flow' | 'script'
scriptOrFlowPath: string
errorHandlerMuted: boolean | undefined
onEdit?: (summary: string, path: string) => void
children?: import('svelte').Snippet
trigger_badges?: import('svelte').Snippet
}
@@ -48,6 +49,7 @@
errorHandlerKind,
scriptOrFlowPath,
errorHandlerMuted = $bindable(),
onEdit,
children,
trigger_badges
}: Props = $props()
@@ -55,29 +57,14 @@
const dispatch = createEventDispatcher()
</script>
<div class="border-b p-1">
<div class="border-b">
<div class="mx-auto">
<div
class="flex w-full flex-wrap md:flex-nowrap justify-end gap-x-2 gap-y-4 items-center min-h-10"
class="flex w-full flex-wrap md:flex-nowrap justify-end gap-x-2 gap-y-4 items-center min-h-12"
>
<div class="grow px-4 inline-flex items-center gap-4 min-w-0">
<div
class={twMerge(
'min-w-24 text-emphasis truncate flex flex-col gap-0',
$userStore?.operator ? 'pl-10' : ''
)}
>
<span
class={twMerge(
'text-sm min-w-24 text-emphasis font-semibold truncate',
$userStore?.operator ? 'pl-10' : ''
)}
>
{emptyString(summary) ? (path ?? '') : summary}
</span>
{#if !emptyString(summary)}
<span class="text-2xs text-secondary">{path}</span>
{/if}
<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} />
</div>
{#if tag}
<Badge>tag: {tag}</Badge>
@@ -104,7 +91,7 @@
{/if}
{@render trigger_badges?.()}
</div>
<div class="flex gap-1 items-center">
<div class="flex gap-1 items-center pr-4">
{#if menuItems.length > 0}
{#key menuItems}
<DropdownV2

View File

@@ -45,6 +45,10 @@
* If provided, the popover will only open if the click is on the element with the given id.
*/
export let targetId: string | undefined = undefined
/**
* Additional CSS selectors whose matching elements should be excluded from outside-click detection.
*/
export let excludeSelectors: string | undefined = undefined
let fullScreen = false
const dispatch = createEventDispatcher()
@@ -138,7 +142,10 @@
}
async function getMenuElements(): Promise<HTMLElement[]> {
return Array.from(document.querySelectorAll('[data-popover]')) as HTMLElement[]
const selector = excludeSelectors
? `[data-popover], ${excludeSelectors}`
: '[data-popover]'
return Array.from(document.querySelectorAll(selector)) as HTMLElement[]
}
let { debounced: debounceClose, clearDebounce: clearDebounceClose } = debounce(

View File

@@ -1504,6 +1504,8 @@ export type Item = {
extra?: Snippet
id?: string
tooltip?: string
separatorTop?: boolean
submenuItems?: Item[]
}
export function isObjectTooBig(obj: any): boolean {

View File

@@ -69,7 +69,7 @@
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
let flow: Flow | undefined = $state()
let can_write = false
let can_write = $state(false)
let shareModal: ShareModal | undefined = $state()
let scheduledForStr: string | undefined = $state(undefined)
@@ -497,6 +497,32 @@
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)
}
}
: undefined}
>
<!-- @migration-task: migrate this slot by hand, `trigger-badges` is an invalid identifier -->
{#snippet trigger_badges()}