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:
83
frontend/src/lib/components/DropdownSubmenuItem.svelte
Normal file
83
frontend/src/lib/components/DropdownSubmenuItem.svelte
Normal 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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
@@ -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] )}
|
||||
|
||||
151
frontend/src/lib/components/SummaryPathDisplay.svelte
Normal file
151
frontend/src/lib/components/SummaryPathDisplay.svelte
Normal 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}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1504,6 +1504,8 @@ export type Item = {
|
||||
extra?: Snippet
|
||||
id?: string
|
||||
tooltip?: string
|
||||
separatorTop?: boolean
|
||||
submenuItems?: Item[]
|
||||
}
|
||||
|
||||
export function isObjectTooBig(obj: any): boolean {
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user