mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-12 00:06:14 +00:00
refactor: unify flow delete planning
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
15e6a60f7a
commit
a837cb03cc
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../aiProviderStorage', () => ({
|
||||
loadStoredConfig: () => undefined
|
||||
}))
|
||||
|
||||
vi.mock('./flowInfers', () => ({
|
||||
AI_AGENT_SCHEMA: { properties: {} }
|
||||
}))
|
||||
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import {
|
||||
collectFlowNodeIds,
|
||||
findAgentToolOwner,
|
||||
removeAgentToolOwner
|
||||
} from './agentToolTree'
|
||||
|
||||
function makeRawModule(id: string): FlowModule {
|
||||
return {
|
||||
id,
|
||||
summary: id,
|
||||
value: { type: 'rawscript', content: '', language: 'python3', input_transforms: {} } as any
|
||||
} as FlowModule
|
||||
}
|
||||
|
||||
function makeAiAgent(id: string, tools: any[]): FlowModule {
|
||||
return {
|
||||
id,
|
||||
summary: id,
|
||||
value: {
|
||||
type: 'aiagent',
|
||||
tools,
|
||||
input_transforms: {}
|
||||
} as any
|
||||
} as FlowModule
|
||||
}
|
||||
|
||||
function makeFlowModuleTool(module: FlowModule) {
|
||||
return {
|
||||
id: module.id,
|
||||
summary: module.summary,
|
||||
value: {
|
||||
tool_type: 'flowmodule',
|
||||
...module.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('findAgentToolOwner', () => {
|
||||
it('finds a direct tool owner in an ai agent', () => {
|
||||
const rootAgent = makeAiAgent('root_agent', [makeFlowModuleTool(makeRawModule('lookup_user'))])
|
||||
|
||||
expect(findAgentToolOwner([rootAgent], 'lookup_user')).toMatchObject({
|
||||
agentId: 'root_agent',
|
||||
toolIndex: 0,
|
||||
depth: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('finds a nested tool owner inside a nested ai agent tool', () => {
|
||||
const nestedAgent = makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))])
|
||||
const rootAgent = makeAiAgent('root_agent', [makeFlowModuleTool(nestedAgent)])
|
||||
|
||||
expect(findAgentToolOwner([rootAgent], 'create_ticket')).toMatchObject({
|
||||
agentId: 'support_agent',
|
||||
toolIndex: 0,
|
||||
depth: 2
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeAgentToolOwner', () => {
|
||||
it('removes the matched tool and returns its subtree ids', () => {
|
||||
const nestedAgent = makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))])
|
||||
const rootAgent = makeAiAgent('root_agent', [
|
||||
makeFlowModuleTool(makeRawModule('lookup_user')),
|
||||
makeFlowModuleTool(nestedAgent)
|
||||
])
|
||||
const owner = findAgentToolOwner([rootAgent], 'support_agent')
|
||||
|
||||
expect(owner).toBeDefined()
|
||||
expect(removeAgentToolOwner(owner!)).toEqual({
|
||||
tool: expect.objectContaining({ id: 'support_agent' }),
|
||||
removedIds: ['support_agent', 'create_ticket']
|
||||
})
|
||||
expect((rootAgent.value as any).tools).toHaveLength(1)
|
||||
expect(((rootAgent.value as any).tools as any[]).map((tool) => tool.id)).toEqual(['lookup_user'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectFlowNodeIds', () => {
|
||||
it('includes ai agent tool ids when deleting an ai agent flow module', () => {
|
||||
const agent = makeAiAgent('root_agent', [
|
||||
makeFlowModuleTool(makeRawModule('lookup_user')),
|
||||
makeFlowModuleTool(makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))]))
|
||||
])
|
||||
|
||||
expect(collectFlowNodeIds(agent)).toEqual([
|
||||
'root_agent',
|
||||
'lookup_user',
|
||||
'support_agent',
|
||||
'create_ticket'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { isFlowModuleTool, type AgentTool, type FlowModuleTool } from './agentToolUtils'
|
||||
|
||||
type FlowNodeLike = Pick<FlowModule, 'id' | 'value'>
|
||||
|
||||
export type AgentToolOwner = {
|
||||
agentId: string
|
||||
tools: AgentTool[]
|
||||
toolIndex: number
|
||||
tool: AgentTool
|
||||
depth: number
|
||||
}
|
||||
|
||||
export type RemovedAgentTool = {
|
||||
tool: AgentTool
|
||||
removedIds: string[]
|
||||
}
|
||||
|
||||
export function findAgentToolOwner(
|
||||
modules: FlowModule[],
|
||||
toolId: string
|
||||
): AgentToolOwner | undefined {
|
||||
return findAgentToolOwnerInModules(modules, toolId, 0)
|
||||
}
|
||||
|
||||
export function removeAgentToolOwner(owner: AgentToolOwner): RemovedAgentTool | undefined {
|
||||
const candidate = owner.tools[owner.toolIndex]
|
||||
if (!candidate || candidate.id !== owner.tool.id) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
owner.tools.splice(owner.toolIndex, 1)
|
||||
return {
|
||||
tool: candidate,
|
||||
removedIds: collectAgentToolIds(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
export function collectFlowNodeIds(module: FlowModule): string[] {
|
||||
return collectFlowNodeIdsFromNode(module)
|
||||
}
|
||||
|
||||
export function collectAgentToolIds(tool: AgentTool): string[] {
|
||||
return collectFlowNodeIdsFromNode(tool as FlowModuleTool)
|
||||
}
|
||||
|
||||
function findAgentToolOwnerInModules(
|
||||
modules: FlowModule[],
|
||||
toolId: string,
|
||||
depth: number
|
||||
): AgentToolOwner | undefined {
|
||||
for (const module of modules) {
|
||||
const owner = findAgentToolOwnerInNode(module, toolId, depth)
|
||||
if (owner) {
|
||||
return owner
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function findAgentToolOwnerInNode(
|
||||
node: FlowNodeLike,
|
||||
toolId: string,
|
||||
depth: number
|
||||
): AgentToolOwner | undefined {
|
||||
if (node.value.type === 'forloopflow' || node.value.type === 'whileloopflow') {
|
||||
return findAgentToolOwnerInModules(node.value.modules, toolId, depth)
|
||||
}
|
||||
|
||||
if (node.value.type === 'branchall') {
|
||||
for (const branch of node.value.branches) {
|
||||
const owner = findAgentToolOwnerInModules(branch.modules, toolId, depth)
|
||||
if (owner) {
|
||||
return owner
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (node.value.type === 'branchone') {
|
||||
const defaultOwner = findAgentToolOwnerInModules(node.value.default, toolId, depth)
|
||||
if (defaultOwner) {
|
||||
return defaultOwner
|
||||
}
|
||||
for (const branch of node.value.branches) {
|
||||
const owner = findAgentToolOwnerInModules(branch.modules, toolId, depth)
|
||||
if (owner) {
|
||||
return owner
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (node.value.type !== 'aiagent') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const toolIndex = node.value.tools.findIndex((tool) => tool.id === toolId)
|
||||
if (toolIndex !== -1) {
|
||||
return {
|
||||
agentId: node.id,
|
||||
tools: node.value.tools,
|
||||
toolIndex,
|
||||
tool: node.value.tools[toolIndex],
|
||||
depth: depth + 1
|
||||
}
|
||||
}
|
||||
|
||||
for (const tool of node.value.tools) {
|
||||
if (!isFlowModuleTool(tool)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const owner = findAgentToolOwnerInNode(tool as FlowNodeLike, toolId, depth + 1)
|
||||
if (owner) {
|
||||
return owner
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function collectFlowNodeIdsFromNode(node: FlowNodeLike): string[] {
|
||||
const ids = [node.id]
|
||||
|
||||
if (node.value.type === 'forloopflow' || node.value.type === 'whileloopflow') {
|
||||
for (const module of node.value.modules) {
|
||||
ids.push(...collectFlowNodeIds(module))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
if (node.value.type === 'branchall') {
|
||||
for (const branch of node.value.branches) {
|
||||
for (const module of branch.modules) {
|
||||
ids.push(...collectFlowNodeIds(module))
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
if (node.value.type === 'branchone') {
|
||||
for (const module of node.value.default) {
|
||||
ids.push(...collectFlowNodeIds(module))
|
||||
}
|
||||
for (const branch of node.value.branches) {
|
||||
for (const module of branch.modules) {
|
||||
ids.push(...collectFlowNodeIds(module))
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
if (node.value.type === 'aiagent') {
|
||||
for (const tool of node.value.tools) {
|
||||
ids.push(...collectAgentToolIds(tool))
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../aiProviderStorage', () => ({
|
||||
loadStoredConfig: () => undefined
|
||||
}))
|
||||
|
||||
vi.mock('./flowInfers', () => ({
|
||||
AI_AGENT_SCHEMA: { properties: {} }
|
||||
}))
|
||||
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { removeAgentToolByIdDeep } from './agentToolUtils'
|
||||
|
||||
function makeRawModule(id: string): FlowModule {
|
||||
return {
|
||||
id,
|
||||
summary: id,
|
||||
value: { type: 'rawscript', content: '', language: 'python3' } as any
|
||||
} as FlowModule
|
||||
}
|
||||
|
||||
function makeAiAgent(id: string, tools: any[]): FlowModule {
|
||||
return {
|
||||
id,
|
||||
summary: id,
|
||||
value: {
|
||||
type: 'aiagent',
|
||||
tools,
|
||||
input_transforms: {}
|
||||
} as any
|
||||
} as FlowModule
|
||||
}
|
||||
|
||||
function makeFlowModuleTool(module: FlowModule) {
|
||||
return {
|
||||
id: module.id,
|
||||
summary: module.summary,
|
||||
value: {
|
||||
tool_type: 'flowmodule',
|
||||
...module.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('removeAgentToolByIdDeep', () => {
|
||||
it('removes a direct tool from an ai agent', () => {
|
||||
const tool = makeFlowModuleTool(makeRawModule('lookup_user'))
|
||||
const agent = makeAiAgent('agent', [tool])
|
||||
const removed: string[] = []
|
||||
|
||||
expect(removeAgentToolByIdDeep([agent], 'lookup_user', (x) => removed.push(x.id))).toBe(true)
|
||||
expect((agent.value as any).tools).toEqual([])
|
||||
expect(removed).toEqual(['lookup_user'])
|
||||
})
|
||||
|
||||
it('removes a nested tool from a nested ai agent tool', () => {
|
||||
const nestedTool = makeFlowModuleTool(makeRawModule('create_ticket'))
|
||||
const nestedAgent = makeAiAgent('support_agent', [nestedTool])
|
||||
const rootAgent = makeAiAgent('root_agent', [makeFlowModuleTool(nestedAgent)])
|
||||
const removed: string[] = []
|
||||
|
||||
expect(removeAgentToolByIdDeep([rootAgent], 'create_ticket', (x) => removed.push(x.id))).toBe(
|
||||
true
|
||||
)
|
||||
expect((((rootAgent.value as any).tools as any[])[0].value as any).tools).toEqual([])
|
||||
expect(removed).toEqual(['create_ticket'])
|
||||
})
|
||||
|
||||
it('returns false when the tool does not exist', () => {
|
||||
const agent = makeAiAgent('agent', [makeFlowModuleTool(makeRawModule('lookup_user'))])
|
||||
const removed: string[] = []
|
||||
|
||||
expect(removeAgentToolByIdDeep([agent], 'missing_tool', (x) => removed.push(x.id))).toBe(false)
|
||||
expect((agent.value as any).tools).toHaveLength(1)
|
||||
expect(removed).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -108,66 +108,6 @@ export function createWebsearchTool(id: string): WebsearchTool {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an AI agent tool by id, recursively traversing nested modules and nested AI agents.
|
||||
* Returns true when a matching tool was found and removed.
|
||||
*/
|
||||
export function removeAgentToolByIdDeep(
|
||||
modules: FlowModule[],
|
||||
id: string,
|
||||
onRemove?: (tool: AgentTool) => void
|
||||
): boolean {
|
||||
for (const module of modules) {
|
||||
if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') {
|
||||
if (removeAgentToolByIdDeep(module.value.modules, id, onRemove)) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (module.value.type === 'branchall') {
|
||||
for (const branch of module.value.branches) {
|
||||
if (removeAgentToolByIdDeep(branch.modules, id, onRemove)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (module.value.type === 'branchone') {
|
||||
if (removeAgentToolByIdDeep(module.value.default, id, onRemove)) {
|
||||
return true
|
||||
}
|
||||
for (const branch of module.value.branches) {
|
||||
if (removeAgentToolByIdDeep(branch.modules, id, onRemove)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (module.value.type !== 'aiagent') {
|
||||
continue
|
||||
}
|
||||
|
||||
const toolIndex = module.value.tools.findIndex((tool) => tool.id === id)
|
||||
if (toolIndex !== -1) {
|
||||
const [removed] = module.value.tools.splice(toolIndex, 1)
|
||||
onRemove?.(removed)
|
||||
return true
|
||||
}
|
||||
|
||||
const nestedToolModules = module.value.tools
|
||||
.filter(isFlowModuleTool)
|
||||
.map((tool) => agentToolToFlowModule(tool))
|
||||
if (removeAgentToolByIdDeep(nestedToolModules, id, onRemove)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a FlowModuleTool to a FlowModule for use with loadFlowModuleState etc.
|
||||
* Strips the extra `tool_type` field and maps AgentTool fields to FlowModule fields.
|
||||
|
||||
@@ -8,15 +8,33 @@ vi.mock('./flowInfers', () => ({
|
||||
AI_AGENT_SCHEMA: { properties: {} }
|
||||
}))
|
||||
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import type { FlowModule, OpenFlow } from '$lib/gen'
|
||||
import type { FlowStructureNode } from '$lib/components/graph/flowStructure'
|
||||
import { partitionDeleteTargets, removeToolIds } from './flowDeleteUtils'
|
||||
import { GroupDisplayState } from '$lib/components/graph/groupEditor.svelte'
|
||||
import type { GroupedModulesProxy } from '$lib/components/graph/groupedModulesProxy.svelte'
|
||||
import {
|
||||
createDeletePlan,
|
||||
removeDeletePlanTools,
|
||||
resolveDeleteTargets
|
||||
} from './flowDeleteUtils'
|
||||
|
||||
function makeRawModule(id: string): FlowModule {
|
||||
function makeRawModule(id: string, expr?: string): FlowModule {
|
||||
return {
|
||||
id,
|
||||
summary: id,
|
||||
value: { type: 'rawscript', content: '', language: 'python3' } as any
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
content: '',
|
||||
language: 'python3',
|
||||
input_transforms: expr
|
||||
? {
|
||||
user_input: {
|
||||
type: 'javascript',
|
||||
expr
|
||||
}
|
||||
}
|
||||
: {}
|
||||
} as any
|
||||
} as FlowModule
|
||||
}
|
||||
|
||||
@@ -43,39 +61,88 @@ function makeFlowModuleTool(module: FlowModule) {
|
||||
}
|
||||
}
|
||||
|
||||
describe('partitionDeleteTargets', () => {
|
||||
it('splits structure nodes from AI tool ids in one pass', () => {
|
||||
const tree: FlowStructureNode[] = [
|
||||
{ id: 'step_a', kind: 'leaf', branches: [] },
|
||||
{
|
||||
id: 'loop',
|
||||
kind: 'forloopflow',
|
||||
branches: [{ children: [{ id: 'nested_step', kind: 'leaf', branches: [] }] }]
|
||||
}
|
||||
]
|
||||
|
||||
expect(partitionDeleteTargets(tree, ['step_a', 'tool_x', 'nested_step'])).toEqual({
|
||||
structureIds: ['step_a', 'nested_step'],
|
||||
toolIds: ['tool_x']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeToolIds', () => {
|
||||
it('returns only the ids that were actually removed', () => {
|
||||
describe('resolveDeleteTargets', () => {
|
||||
it('resolves structure nodes, preprocessor, and ai tools while pruning nested descendants', () => {
|
||||
const rootAgent = makeAiAgent('root_agent', [
|
||||
makeFlowModuleTool(makeRawModule('lookup_user')),
|
||||
makeFlowModuleTool(makeAiAgent('nested_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))]))
|
||||
makeFlowModuleTool(makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))]))
|
||||
])
|
||||
const removed: string[] = []
|
||||
const tree: FlowStructureNode[] = [{ id: 'root_agent', kind: 'leaf', branches: [] }]
|
||||
|
||||
expect(
|
||||
removeToolIds([rootAgent], ['lookup_user', 'missing_tool', 'create_ticket'], (tool) => {
|
||||
removed.push(tool.id)
|
||||
})
|
||||
).toEqual(['lookup_user', 'create_ticket'])
|
||||
expect((rootAgent.value as any).tools).toHaveLength(1)
|
||||
expect((((rootAgent.value as any).tools as any[])[0].value as any).tools).toEqual([])
|
||||
expect(removed).toEqual(['lookup_user', 'create_ticket'])
|
||||
const { targets, missingIds } = resolveDeleteTargets(
|
||||
tree,
|
||||
[rootAgent],
|
||||
['preprocessor', 'root_agent', 'create_ticket', 'missing_tool'],
|
||||
true
|
||||
)
|
||||
|
||||
expect(targets.map((target) => target.kind)).toEqual(['preprocessor', 'structure_node'])
|
||||
expect(targets[1].stateIds).toEqual([
|
||||
'root_agent',
|
||||
'lookup_user',
|
||||
'support_agent',
|
||||
'create_ticket'
|
||||
])
|
||||
expect(missingIds).toEqual(['missing_tool'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDeletePlan', () => {
|
||||
it('collects subtree dependents and routes structure deletes through the proxy helper', () => {
|
||||
const agent = makeAiAgent('agent_step', [makeFlowModuleTool(makeRawModule('lookup_user'))])
|
||||
const dependent = makeRawModule('dependent_step', 'results.lookup_user?.value')
|
||||
const flow: OpenFlow = {
|
||||
summary: 'Flow',
|
||||
value: {
|
||||
modules: [agent, dependent]
|
||||
}
|
||||
}
|
||||
const tree: FlowStructureNode[] = [
|
||||
{ id: 'agent_step', kind: 'leaf', branches: [] },
|
||||
{ id: 'dependent_step', kind: 'leaf', branches: [] }
|
||||
]
|
||||
const commit = vi.fn()
|
||||
const prepareDelete = vi.fn(() => ({
|
||||
removedIds: ['agent_step'],
|
||||
affectedGroups: [],
|
||||
duplicateGroups: [],
|
||||
commit
|
||||
}))
|
||||
const proxy = {
|
||||
prepareDelete
|
||||
} as unknown as GroupedModulesProxy
|
||||
|
||||
const plan = createDeletePlan({
|
||||
ids: ['agent_step'],
|
||||
flow,
|
||||
tree,
|
||||
proxy,
|
||||
displayState: new GroupDisplayState(() => [])
|
||||
})
|
||||
|
||||
expect(prepareDelete).toHaveBeenCalledWith(['agent_step'], expect.any(Object))
|
||||
expect(plan?.stateIds).toEqual(['agent_step', 'lookup_user'])
|
||||
expect(plan?.dependents).toEqual({
|
||||
dependent_step: ['results.lookup_user?.value']
|
||||
})
|
||||
expect(plan?.selection).toEqual({ kind: 'select', id: 'dependent_step' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeDeletePlanTools', () => {
|
||||
it('removes nested tools before their parents and returns every removed id', () => {
|
||||
const rootAgent = makeAiAgent('root_agent', [
|
||||
makeFlowModuleTool(makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))]))
|
||||
])
|
||||
const tree: FlowStructureNode[] = []
|
||||
const { targets } = resolveDeleteTargets(
|
||||
tree,
|
||||
[rootAgent],
|
||||
['support_agent', 'create_ticket'],
|
||||
false
|
||||
)
|
||||
|
||||
expect(removeDeletePlanTools(targets)).toEqual(['support_agent', 'create_ticket'])
|
||||
expect((rootAgent.value as any).tools).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,51 +1,267 @@
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import type { GroupDisplayState } from '$lib/components/graph/groupEditor.svelte'
|
||||
import type {
|
||||
GroupedModulesProxy,
|
||||
PreparedStructureDelete
|
||||
} from '$lib/components/graph/groupedModulesProxy.svelte'
|
||||
import { findInStructure, type FlowStructureNode } from '$lib/components/graph/flowStructure'
|
||||
import type { FlowModule, OpenFlow } from '$lib/gen'
|
||||
import {
|
||||
collectAgentToolIds,
|
||||
collectFlowNodeIds,
|
||||
findAgentToolOwner,
|
||||
removeAgentToolOwner,
|
||||
type AgentToolOwner
|
||||
} from './agentToolTree'
|
||||
import type { AgentTool } from './agentToolUtils'
|
||||
import { removeAgentToolByIdDeep } from './agentToolUtils'
|
||||
import { dfs } from './dfs'
|
||||
import { getDependentComponents } from './flowExplorer'
|
||||
import { dfsByModule } from './previousResults'
|
||||
|
||||
export type DeleteTargetPartition = {
|
||||
structureIds: string[]
|
||||
toolIds: string[]
|
||||
export type DeleteSelection =
|
||||
| { kind: 'clear' }
|
||||
| {
|
||||
kind: 'select'
|
||||
id: string
|
||||
}
|
||||
|
||||
type DeleteTargetBase = {
|
||||
id: string
|
||||
stateIds: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Split delete targets between structure-tree nodes and AI agent tool nodes.
|
||||
* AI tools are rendered in the graph but are not represented in the grouped structure tree.
|
||||
*/
|
||||
export function partitionDeleteTargets(
|
||||
export type PreprocessorDeleteTarget = DeleteTargetBase & {
|
||||
kind: 'preprocessor'
|
||||
}
|
||||
|
||||
export type StructureDeleteTarget = DeleteTargetBase & {
|
||||
kind: 'structure_node'
|
||||
}
|
||||
|
||||
export type AgentToolDeleteTarget = DeleteTargetBase & {
|
||||
kind: 'agent_tool'
|
||||
owner: AgentToolOwner
|
||||
}
|
||||
|
||||
export type DeleteTarget =
|
||||
| PreprocessorDeleteTarget
|
||||
| StructureDeleteTarget
|
||||
| AgentToolDeleteTarget
|
||||
|
||||
export type DeletePlan = {
|
||||
inputIds: string[]
|
||||
targets: DeleteTarget[]
|
||||
stateIds: string[]
|
||||
dependents: Record<string, string[]>
|
||||
selection: DeleteSelection
|
||||
structureDelete?: PreparedStructureDelete
|
||||
removeDuplicates: boolean
|
||||
}
|
||||
|
||||
export type ResolvedDeleteTargets = {
|
||||
targets: DeleteTarget[]
|
||||
missingIds: string[]
|
||||
}
|
||||
|
||||
export function resolveDeleteTargets(
|
||||
tree: FlowStructureNode[],
|
||||
ids: string[]
|
||||
): DeleteTargetPartition {
|
||||
const structureIds: string[] = []
|
||||
const toolIds: string[] = []
|
||||
|
||||
for (const id of ids) {
|
||||
if (findInStructure(tree, id)) {
|
||||
structureIds.push(id)
|
||||
} else {
|
||||
toolIds.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
return { structureIds, toolIds }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove AI agent tools by id and return the ids that were actually removed.
|
||||
*/
|
||||
export function removeToolIds(
|
||||
modules: FlowModule[],
|
||||
ids: string[],
|
||||
hasPreprocessor: boolean
|
||||
): ResolvedDeleteTargets {
|
||||
const targets: DeleteTarget[] = []
|
||||
const missingIds: string[] = []
|
||||
const seenIds = new Set<string>()
|
||||
|
||||
for (const id of ids) {
|
||||
if (seenIds.has(id)) {
|
||||
continue
|
||||
}
|
||||
seenIds.add(id)
|
||||
|
||||
if (id === 'preprocessor') {
|
||||
if (hasPreprocessor) {
|
||||
targets.push({ kind: 'preprocessor', id, stateIds: [id] })
|
||||
} else {
|
||||
missingIds.push(id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (findInStructure(tree, id)) {
|
||||
const module = findFlowModuleById(id, modules)
|
||||
if (module) {
|
||||
targets.push({
|
||||
kind: 'structure_node',
|
||||
id,
|
||||
stateIds: collectFlowNodeIds(module)
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const owner = findAgentToolOwner(modules, id)
|
||||
if (owner) {
|
||||
targets.push({
|
||||
kind: 'agent_tool',
|
||||
id,
|
||||
owner,
|
||||
stateIds: collectAgentToolIds(owner.tool)
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
missingIds.push(id)
|
||||
}
|
||||
|
||||
return {
|
||||
targets: pruneNestedTargets(targets),
|
||||
missingIds
|
||||
}
|
||||
}
|
||||
|
||||
export function createDeletePlan(args: {
|
||||
ids: string[]
|
||||
flow: OpenFlow
|
||||
tree: FlowStructureNode[]
|
||||
proxy: GroupedModulesProxy
|
||||
displayState: GroupDisplayState
|
||||
}): DeletePlan | undefined {
|
||||
const { targets } = resolveDeleteTargets(
|
||||
args.tree,
|
||||
args.flow.value.modules,
|
||||
args.ids,
|
||||
Boolean(args.flow.value.preprocessor_module)
|
||||
)
|
||||
|
||||
if (targets.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const structureIds = targets
|
||||
.filter((target): target is StructureDeleteTarget => target.kind === 'structure_node')
|
||||
.map((target) => target.id)
|
||||
|
||||
const structureDelete =
|
||||
structureIds.length > 0
|
||||
? args.proxy.prepareDelete(structureIds, { displayState: args.displayState })
|
||||
: undefined
|
||||
|
||||
const stateIds = uniqueIds(targets.flatMap((target) => target.stateIds))
|
||||
|
||||
return {
|
||||
inputIds: args.ids,
|
||||
targets,
|
||||
stateIds,
|
||||
dependents: collectDeleteDependents(stateIds, args.flow),
|
||||
selection: getDeleteSelection(args.ids, stateIds, args.flow.value.modules),
|
||||
structureDelete,
|
||||
removeDuplicates: Boolean(structureDelete && structureDelete.duplicateGroups.length > 0)
|
||||
}
|
||||
}
|
||||
|
||||
export function removeDeletePlanTools(
|
||||
targets: DeleteTarget[],
|
||||
onRemove?: (tool: AgentTool) => void
|
||||
): string[] {
|
||||
const removedIds = new Set<string>()
|
||||
|
||||
for (const id of ids) {
|
||||
removeAgentToolByIdDeep(modules, id, (tool) => {
|
||||
removedIds.add(tool.id)
|
||||
onRemove?.(tool)
|
||||
const toolTargets = targets
|
||||
.filter((target): target is AgentToolDeleteTarget => target.kind === 'agent_tool')
|
||||
.sort((left, right) => {
|
||||
if (left.owner.depth !== right.owner.depth) {
|
||||
return right.owner.depth - left.owner.depth
|
||||
}
|
||||
if (left.owner.tools === right.owner.tools) {
|
||||
return right.owner.toolIndex - left.owner.toolIndex
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
for (const target of toolTargets) {
|
||||
const removed = removeAgentToolOwner(target.owner)
|
||||
if (!removed) {
|
||||
continue
|
||||
}
|
||||
|
||||
onRemove?.(removed.tool)
|
||||
for (const id of removed.removedIds) {
|
||||
removedIds.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
return [...removedIds]
|
||||
}
|
||||
|
||||
function findFlowModuleById(id: string, modules: FlowModule[]): FlowModule | undefined {
|
||||
return dfsByModule(id, modules)[0]
|
||||
}
|
||||
|
||||
function pruneNestedTargets(targets: DeleteTarget[]): DeleteTarget[] {
|
||||
const descendantIds = new Set<string>()
|
||||
|
||||
for (const target of targets) {
|
||||
for (const stateId of target.stateIds) {
|
||||
if (stateId !== target.id) {
|
||||
descendantIds.add(stateId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return targets.filter((target) => !descendantIds.has(target.id))
|
||||
}
|
||||
|
||||
function collectDeleteDependents(ids: string[], flow: OpenFlow): Record<string, string[]> {
|
||||
const deletingSet = new Set(ids)
|
||||
const dependents: Record<string, string[]> = {}
|
||||
|
||||
for (const id of ids) {
|
||||
const dependencies = getDependentComponents(id, flow)
|
||||
for (const [dependentId, expressions] of Object.entries(dependencies)) {
|
||||
if (deletingSet.has(dependentId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
dependents[dependentId] = [...(dependents[dependentId] ?? []), ...expressions]
|
||||
}
|
||||
}
|
||||
|
||||
return dependents
|
||||
}
|
||||
|
||||
function getDeleteSelection(
|
||||
ids: string[],
|
||||
deletedIds: string[],
|
||||
modules: FlowModule[]
|
||||
): DeleteSelection {
|
||||
if (ids.length !== 1) {
|
||||
return { kind: 'clear' }
|
||||
}
|
||||
|
||||
const [id] = ids
|
||||
if (id === 'preprocessor') {
|
||||
return { kind: 'select', id: 'Input' }
|
||||
}
|
||||
|
||||
const orderedIds = dfs(modules, (module) => module.id)
|
||||
const index = orderedIds.indexOf(id)
|
||||
const deletedSet = new Set(deletedIds)
|
||||
|
||||
for (let i = index - 1; i >= 0; i--) {
|
||||
if (!deletedSet.has(orderedIds[i])) {
|
||||
return { kind: 'select', id: orderedIds[i] }
|
||||
}
|
||||
}
|
||||
for (let i = index + 1; i < orderedIds.length; i++) {
|
||||
if (!deletedSet.has(orderedIds[i])) {
|
||||
return { kind: 'select', id: orderedIds[i] }
|
||||
}
|
||||
}
|
||||
if (index === -1) {
|
||||
return { kind: 'select', id: 'settings-metadata' }
|
||||
}
|
||||
|
||||
return { kind: 'select', id: 'settings-metadata' }
|
||||
}
|
||||
|
||||
function uniqueIds(ids: string[]): string[] {
|
||||
return [...new Set(ids)]
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
|
||||
import { getAllModules, getDependentComponents } from '../flowExplorer'
|
||||
import { getAllModules } from '../flowExplorer'
|
||||
import { locateModules, groupByParent } from '../multiSelectUtils'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
@@ -53,18 +53,24 @@
|
||||
agentToolToFlowModule
|
||||
} from '../agentToolUtils'
|
||||
import { loadFlowModuleState } from '../flowStateUtils.svelte'
|
||||
import { partitionDeleteTargets, removeToolIds } from '../flowDeleteUtils'
|
||||
import {
|
||||
createDeletePlan,
|
||||
removeDeletePlanTools,
|
||||
type DeletePlan
|
||||
} from '../flowDeleteUtils'
|
||||
import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte'
|
||||
import {
|
||||
GroupedModulesProxy,
|
||||
type ExtendedOpenFlow
|
||||
} from '$lib/components/graph/groupedModulesProxy.svelte'
|
||||
import { GroupDisplayState } from '$lib/components/graph/groupEditor.svelte'
|
||||
import {
|
||||
GroupDisplayState,
|
||||
type FlowGroup
|
||||
} from '$lib/components/graph/groupEditor.svelte'
|
||||
import {
|
||||
type FlowStructureNode,
|
||||
matchStructureNode,
|
||||
dfsStructure,
|
||||
findInStructure,
|
||||
moduleToStructureNode
|
||||
} from '$lib/components/graph/flowStructure'
|
||||
|
||||
@@ -259,18 +265,6 @@
|
||||
let flowPaneWidth = $state(0)
|
||||
let compactTopbar = $derived(flowPaneWidth < 700)
|
||||
|
||||
export function selectNextId(id: any) {
|
||||
if (flowStore.val.value.modules) {
|
||||
let allIds = dfs(flowStore.val.value.modules, (mod) => mod.id)
|
||||
if (allIds.length > 1) {
|
||||
const idx = allIds.indexOf(id)
|
||||
selectionManager.selectId(idx == 0 ? allIds[0] : allIds[idx - 1])
|
||||
} else {
|
||||
selectionManager.selectId('settings-metadata')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findModuleById(id: string) {
|
||||
return dfsByModule(id, flowStore.val.value.modules)[0]
|
||||
}
|
||||
@@ -312,15 +306,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
let deleteCallback: (() => void) | undefined = $state(undefined)
|
||||
let dependents: Record<string, string[]> = $state({})
|
||||
type PendingDeleteConfirmation = {
|
||||
plan: DeletePlan
|
||||
}
|
||||
|
||||
/** Confirmation gate for actions that would empty or duplicate groups */
|
||||
let affectedGroupsPending: import('$lib/components/graph/groupEditor.svelte').FlowGroup[] =
|
||||
$state([])
|
||||
let affectedGroupsAction: (() => void) | undefined = $state(undefined)
|
||||
let affectedGroupsCancel: (() => void) | undefined = $state(undefined)
|
||||
let affectedGroupsActionLabel: 'delete' | 'move' = $state('delete')
|
||||
type PendingGroupAction = {
|
||||
groups: FlowGroup[]
|
||||
label: 'delete' | 'move'
|
||||
confirm: () => void
|
||||
cancel?: () => void
|
||||
}
|
||||
|
||||
let pendingDeleteConfirmation: PendingDeleteConfirmation | undefined = $state(undefined)
|
||||
let pendingGroupAction: PendingGroupAction | undefined = $state(undefined)
|
||||
|
||||
let graph: FlowGraphV2 | undefined = $state(undefined)
|
||||
let noteMode = $state(false)
|
||||
@@ -341,69 +339,68 @@
|
||||
noteMode = !noteMode
|
||||
}
|
||||
|
||||
export function deleteMultiple(ids: string[]) {
|
||||
const { structureIds, toolIds } = partitionDeleteTargets(proxy.items, ids)
|
||||
const deletingSet = new Set(ids)
|
||||
const allDeps: Record<string, string[]> = {}
|
||||
for (const id of ids) {
|
||||
const deps = getDependentComponents(id, flowStore.val)
|
||||
for (const [depId, exprs] of Object.entries(deps)) {
|
||||
if (!deletingSet.has(depId)) {
|
||||
allDeps[depId] = [...(allDeps[depId] ?? []), ...exprs]
|
||||
}
|
||||
}
|
||||
function applyDeletePlan(plan: DeletePlan) {
|
||||
push(history, flowStore.val)
|
||||
|
||||
if (plan.selection.kind === 'clear') {
|
||||
selectionManager.clearSelection()
|
||||
} else {
|
||||
selectionManager.selectId(plan.selection.id)
|
||||
}
|
||||
|
||||
const opts = { displayState: groupDisplayState }
|
||||
const { emptiedGroups, duplicateGroups, commit } =
|
||||
structureIds.length > 0
|
||||
? proxy.prepareMutation((tree) => {
|
||||
for (const id of structureIds) {
|
||||
const found = findInStructure(tree, id)
|
||||
if (found) found.parentChildren.splice(found.index, 1)
|
||||
}
|
||||
}, opts)
|
||||
: {
|
||||
emptiedGroups: [],
|
||||
duplicateGroups: [],
|
||||
commit: () => {}
|
||||
}
|
||||
if (plan.targets.some((target) => target.kind === 'preprocessor')) {
|
||||
flowStore.val.value.preprocessor_module = undefined
|
||||
}
|
||||
|
||||
const affectedGroups = [...emptiedGroups, ...duplicateGroups]
|
||||
plan.structureDelete?.commit({ removeDuplicates: plan.removeDuplicates })
|
||||
removeDeletePlanTools(plan.targets)
|
||||
|
||||
const cb = () => {
|
||||
push(history, flowStore.val)
|
||||
commit({ removeDuplicates: duplicateGroups.length > 0 })
|
||||
const removedToolIds = removeToolIds(flowStore.val.value.modules, toolIds, (tool) => {
|
||||
deleteFlowStateById(tool.id, flowStateStore)
|
||||
})
|
||||
for (const id of ids) {
|
||||
if (structureIds.includes(id) || removedToolIds.includes(id)) {
|
||||
delete flowStateStore.val[id]
|
||||
}
|
||||
}
|
||||
selectionManager.clearSelection()
|
||||
refreshStateStore(flowStore)
|
||||
for (const id of plan.stateIds) {
|
||||
deleteFlowStateById(id, flowStateStore)
|
||||
}
|
||||
|
||||
refreshStateStore(flowStore)
|
||||
|
||||
if (plan.inputIds.length === 1) {
|
||||
onDelete?.(plan.targets[0]?.id ?? plan.inputIds[0])
|
||||
}
|
||||
}
|
||||
|
||||
function requestDelete(ids: string[]) {
|
||||
const plan = createDeletePlan({
|
||||
ids,
|
||||
flow: flowStore.val,
|
||||
tree: proxy.items,
|
||||
proxy,
|
||||
displayState: groupDisplayState
|
||||
})
|
||||
if (!plan) {
|
||||
return
|
||||
}
|
||||
|
||||
const proceed = () => {
|
||||
if (Object.keys(allDeps).length > 0) {
|
||||
dependents = allDeps
|
||||
deleteCallback = cb
|
||||
if (Object.keys(plan.dependents).length > 0) {
|
||||
pendingDeleteConfirmation = { plan }
|
||||
} else {
|
||||
cb()
|
||||
applyDeletePlan(plan)
|
||||
}
|
||||
}
|
||||
|
||||
if (affectedGroups.length > 0) {
|
||||
affectedGroupsPending = affectedGroups
|
||||
affectedGroupsActionLabel = 'delete'
|
||||
affectedGroupsAction = proceed
|
||||
if ((plan.structureDelete?.affectedGroups.length ?? 0) > 0) {
|
||||
pendingGroupAction = {
|
||||
groups: plan.structureDelete!.affectedGroups,
|
||||
label: 'delete',
|
||||
confirm: proceed
|
||||
}
|
||||
} else {
|
||||
proceed()
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteMultiple(ids: string[]) {
|
||||
requestDelete(ids)
|
||||
}
|
||||
|
||||
// Operates directly on the flat module array (not the structure tree).
|
||||
// Cloned modules are inserted after the originals, intentionally outside any group.
|
||||
export function duplicateMultiple(ids: string[]) {
|
||||
@@ -512,21 +509,21 @@
|
||||
<ConfirmationModal
|
||||
title="Confirm deleting step with dependents"
|
||||
confirmationText="Delete step"
|
||||
open={Boolean(deleteCallback)}
|
||||
open={Boolean(pendingDeleteConfirmation)}
|
||||
on:confirmed={() => {
|
||||
if (deleteCallback) {
|
||||
deleteCallback()
|
||||
deleteCallback = undefined
|
||||
if (pendingDeleteConfirmation) {
|
||||
applyDeletePlan(pendingDeleteConfirmation.plan)
|
||||
pendingDeleteConfirmation = undefined
|
||||
}
|
||||
}}
|
||||
on:canceled={() => {
|
||||
deleteCallback = undefined
|
||||
pendingDeleteConfirmation = undefined
|
||||
}}
|
||||
>
|
||||
<div class="text-primary pb-2"
|
||||
>Found the following steps that will require changes after this step is deleted:</div
|
||||
>
|
||||
{#each Object.entries(dependents) as [k, v]}
|
||||
{#each Object.entries(pendingDeleteConfirmation?.plan.dependents ?? {}) as [k, v]}
|
||||
<div class="pb-3">
|
||||
<h3 class="text-secondary font-semibold">{k}</h3>
|
||||
<ul class="text-sm">
|
||||
@@ -539,36 +536,32 @@
|
||||
</ConfirmationModal>
|
||||
|
||||
<ConfirmationModal
|
||||
title={affectedGroupsPending.length === 1 ? 'Remove group?' : 'Remove groups?'}
|
||||
confirmationText={affectedGroupsActionLabel === 'delete' ? 'Delete step' : 'Move step'}
|
||||
open={affectedGroupsPending.length > 0}
|
||||
title={pendingGroupAction?.groups.length === 1 ? 'Remove group?' : 'Remove groups?'}
|
||||
confirmationText={pendingGroupAction?.label === 'delete' ? 'Delete step' : 'Move step'}
|
||||
open={Boolean(pendingGroupAction)}
|
||||
on:confirmed={() => {
|
||||
affectedGroupsAction?.()
|
||||
affectedGroupsPending = []
|
||||
affectedGroupsAction = undefined
|
||||
affectedGroupsCancel = undefined
|
||||
pendingGroupAction?.confirm()
|
||||
pendingGroupAction = undefined
|
||||
}}
|
||||
on:canceled={() => {
|
||||
affectedGroupsCancel?.()
|
||||
affectedGroupsPending = []
|
||||
affectedGroupsAction = undefined
|
||||
affectedGroupsCancel = undefined
|
||||
pendingGroupAction?.cancel?.()
|
||||
pendingGroupAction = undefined
|
||||
}}
|
||||
>
|
||||
{#if affectedGroupsPending.length === 1}
|
||||
{@const group = affectedGroupsPending[0]}
|
||||
{#if pendingGroupAction?.groups.length === 1}
|
||||
{@const group = pendingGroupAction.groups[0]}
|
||||
<p
|
||||
>The group{group.summary ? ` "${group.summary}"` : ''} will be removed (empty or duplicate).
|
||||
Are you sure you want to {affectedGroupsActionLabel} the step?</p
|
||||
Are you sure you want to {pendingGroupAction.label} the step?</p
|
||||
>
|
||||
{:else}
|
||||
<p>The following groups will be removed (empty or duplicate):</p>
|
||||
<ul class="list-disc pl-4 mt-1">
|
||||
{#each affectedGroupsPending as group}
|
||||
{#each pendingGroupAction?.groups ?? [] as group}
|
||||
<li>{group.summary || `${group.start_id} → ${group.end_id}`}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="mt-2">Are you sure you want to {affectedGroupsActionLabel} the step?</p>
|
||||
<p class="mt-2">Are you sure you want to {pendingGroupAction?.label} the step?</p>
|
||||
{/if}
|
||||
</ConfirmationModal>
|
||||
</Portal>
|
||||
@@ -630,78 +623,7 @@
|
||||
suspendStatus={suspendStatus.val}
|
||||
{flowHasChanged}
|
||||
chatInputEnabled={Boolean(flowStore.val.value?.chat_input_enabled)}
|
||||
onDelete={(id) => {
|
||||
dependents = getDependentComponents(id, flowStore.val)
|
||||
|
||||
if (id === 'preprocessor') {
|
||||
const cb = () => {
|
||||
push(history, flowStore.val)
|
||||
selectionManager.selectId('Input')
|
||||
flowStore.val.value.preprocessor_module = undefined
|
||||
refreshStateStore(flowStore)
|
||||
onDelete?.(id)
|
||||
delete flowStateStore.val[id]
|
||||
}
|
||||
if (Object.keys(dependents).length > 0) {
|
||||
deleteCallback = cb
|
||||
} else {
|
||||
cb()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!findInStructure(proxy.items, id)) {
|
||||
const cb = () => {
|
||||
push(history, flowStore.val)
|
||||
selectNextId(id)
|
||||
const removedIds = removeToolIds(flowStore.val.value.modules, [id], (tool) => {
|
||||
deleteFlowStateById(tool.id, flowStateStore)
|
||||
})
|
||||
if (removedIds.length === 0) return
|
||||
refreshStateStore(flowStore)
|
||||
onDelete?.(id)
|
||||
}
|
||||
if (Object.keys(dependents).length > 0) {
|
||||
deleteCallback = cb
|
||||
} else {
|
||||
cb()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const dsOpts = { displayState: groupDisplayState }
|
||||
const { emptiedGroups, duplicateGroups, commit } = proxy.prepareMutation((tree) => {
|
||||
const found = findInStructure(tree, id)
|
||||
if (found) found.parentChildren.splice(found.index, 1)
|
||||
}, dsOpts)
|
||||
|
||||
const affectedGroups = [...emptiedGroups, ...duplicateGroups]
|
||||
|
||||
const cb = () => {
|
||||
push(history, flowStore.val)
|
||||
selectNextId(id)
|
||||
commit({ removeDuplicates: duplicateGroups.length > 0 })
|
||||
refreshStateStore(flowStore)
|
||||
onDelete?.(id)
|
||||
delete flowStateStore.val[id]
|
||||
}
|
||||
|
||||
const proceed = () => {
|
||||
if (Object.keys(dependents).length > 0) {
|
||||
deleteCallback = cb
|
||||
} else {
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
if (affectedGroups.length > 0) {
|
||||
affectedGroupsPending = affectedGroups
|
||||
affectedGroupsActionLabel = 'delete'
|
||||
affectedGroupsAction = proceed
|
||||
} else {
|
||||
proceed()
|
||||
}
|
||||
}}
|
||||
onDelete={(id) => requestDelete([id])}
|
||||
onInsert={async (detail) => {
|
||||
if (!flowStore.val.value.modules || !Array.isArray(flowStore.val.value.modules)) return
|
||||
await tick()
|
||||
@@ -780,10 +702,12 @@
|
||||
}
|
||||
|
||||
if (affectedGroups.length > 0) {
|
||||
affectedGroupsPending = affectedGroups
|
||||
affectedGroupsActionLabel = 'move'
|
||||
affectedGroupsAction = doMove
|
||||
affectedGroupsCancel = () => moveManager.clearMoving()
|
||||
pendingGroupAction = {
|
||||
groups: affectedGroups,
|
||||
label: 'move',
|
||||
confirm: doMove,
|
||||
cancel: () => moveManager.clearMoving()
|
||||
}
|
||||
} else {
|
||||
doMove()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
findDuplicateGroups,
|
||||
removeDuplicateGroups,
|
||||
flattenStructureIds,
|
||||
findInStructure,
|
||||
type FlowStructureNode
|
||||
} from './flowStructure'
|
||||
|
||||
@@ -25,6 +26,13 @@ export type ExtendedOpenFlow = {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export type PreparedStructureDelete = {
|
||||
removedIds: string[]
|
||||
affectedGroups: FlowGroup[]
|
||||
duplicateGroups: FlowGroup[]
|
||||
commit: (commitOpts?: { removeDuplicates?: boolean }) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive read-only view of the flow structure tree.
|
||||
* The tree is always derived from flowStore (single source of truth).
|
||||
@@ -112,6 +120,33 @@ export class GroupedModulesProxy {
|
||||
return { emptiedGroups, duplicateGroups, commit }
|
||||
}
|
||||
|
||||
prepareDelete(
|
||||
ids: string[],
|
||||
opts?: {
|
||||
displayState?: import('./groupEditor.svelte').GroupDisplayState
|
||||
}
|
||||
): PreparedStructureDelete {
|
||||
const removedIds: string[] = []
|
||||
const { emptiedGroups, duplicateGroups, commit } = this.prepareMutation((tree) => {
|
||||
for (const id of ids) {
|
||||
const found = findInStructure(tree, id)
|
||||
if (!found) {
|
||||
continue
|
||||
}
|
||||
|
||||
found.parentChildren.splice(found.index, 1)
|
||||
removedIds.push(id)
|
||||
}
|
||||
}, opts)
|
||||
|
||||
return {
|
||||
removedIds,
|
||||
affectedGroups: [...emptiedGroups, ...duplicateGroups],
|
||||
duplicateGroups,
|
||||
commit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: prepare + auto-commit. Only use for mutations that cannot
|
||||
* empty groups (e.g. inserts). Throws if groups are unexpectedly emptied.
|
||||
|
||||
Reference in New Issue
Block a user