Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
276 changes: 274 additions & 2 deletions package-lock.json

Large diffs are not rendered by default.

169 changes: 169 additions & 0 deletions packages/web/__tests__/flow-mutations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { describe, expect, it } from 'vitest'
import YAML from 'yaml'
import { parseFlowYaml } from '@openhop/shared'
import { buildStarterYaml } from '../src/lib/starter-yaml'

const VALID_YAML = `meta:
title: Test
flow:
nodes:
- id: a
label: A
type: actor
- id: b
label: B
type: endpoint
steps:
- from: a
to: b
data: req
`

describe('FlowEditorModal validation handshake', () => {
it('passes parseFlowYaml on the canned starter YAML used for "+ New flow"', () => {
// Mirror the STARTER_YAML constant in FlowEditorModal so the green-on-open
// promise from #74 ("opens with valid starter YAML") doesn't regress.
const STARTER_YAML = `meta:
title: New flow
flow:
nodes:
- id: browser
label: Browser
type: actor
- id: api
label: API
type: endpoint
steps:
- from: browser
to: api
data: request
- from: api
to: browser
data: response
`
const result = parseFlowYaml(STARTER_YAML)
expect(result.success).toBe(true)
})

it('reports path + message + suggestion for an unknown step ref', () => {
// Use a typo close enough that the validator's findClosest() returns a
// suggestion (Levenshtein-bounded). Refer to "dbb" when "db" exists →
// "Did you mean \"db\"?". The earlier "nonexistent" pointed at no
// similar id and so the hint silently dropped, missing the contract.
const bad = `meta:
title: T
flow:
nodes:
- id: api
label: API
type: endpoint
- id: db
label: DB
type: database
steps:
- from: api
to: dbb
data: x
`
const result = parseFlowYaml(bad)
expect(result.success).toBe(false)
expect(result.errors.length).toBeGreaterThan(0)
const err = result.errors[0]
expect(err.path).toBe('flow.steps[0].to')
expect(err.message.toLowerCase()).toContain('node')
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Lock the modal's "[path]: msg — hint" contract: dropping the
// suggestion would silently lose the typo hint.
expect(err.suggestion).toBeTruthy()
expect(err.suggestion?.toLowerCase()).toContain('did you mean')
})

it('returns a validation error when the YAML is malformed / empty', () => {
const result = parseFlowYaml('::: not yaml')
expect(result.success).toBe(false)
expect(result.errors.length).toBeGreaterThan(0)
})

it('round-trips a stored flow through YAML.stringify → YAML.parse without lossy schema changes', () => {
// The Edit-mode pre-population uses YAML.stringify({ meta, flow }) on the
// server response. This test locks in that the round-trip preserves the
// shape parseFlowYaml accepts — i.e. the user's first keystroke in the
// editor doesn't fail validation just from the round-trip.
const stored = {
meta: { title: 'Round trip', description: 'A test' },
flow: {
nodes: [
{ id: 'a', label: 'A', type: 'actor' as const },
{ id: 'b', label: 'B', type: 'endpoint' as const },
],
steps: [{ from: 'a', to: 'b', data: 'req' }],
},
}
const yamlText = YAML.stringify(stored)
const reparsed = parseFlowYaml(yamlText)
expect(reparsed.success).toBe(true)
expect(reparsed.data?.meta.title).toBe('Round trip')
expect(reparsed.data?.flow.nodes).toHaveLength(2)
})

it('canned VALID_YAML is what the e2e fetch test would POST', () => {
expect(parseFlowYaml(VALID_YAML).success).toBe(true)
})
})

describe('handleDeleteFolder descendant match — what gets bulk-deleted', () => {
// Mirror the predicate in App.handleDeleteFolder: a flow is in the folder
// when its path equals the folder OR starts with `${folder}/`. Locking the
// semantics so "delete folder billing" doesn't accidentally take billing-x
// or x/billing.
const inFolder = (folderPath: string, flowPath: string | undefined): boolean =>
flowPath === folderPath || (flowPath ?? '').startsWith(`${folderPath}/`)

it('matches the folder itself', () => {
expect(inFolder('billing', 'billing')).toBe(true)
})

it('matches descendants of the folder', () => {
expect(inFolder('billing', 'billing/refunds')).toBe(true)
expect(inFolder('billing', 'billing/refunds/q1')).toBe(true)
})

it('does NOT match siblings with the folder name as a prefix', () => {
expect(inFolder('billing', 'billing-tax')).toBe(false)
expect(inFolder('billing', 'billing2')).toBe(false)
})

it('does NOT match unrelated paths or root flows', () => {
expect(inFolder('billing', 'orders')).toBe(false)
expect(inFolder('billing', undefined)).toBe(false) // a flow at the root
})

it('does NOT match folder appearing as a non-prefix segment', () => {
expect(inFolder('billing', 'eu/billing')).toBe(false)
})
})

describe('buildStarterYaml — path injection for the per-folder "+" menu', () => {
it('omits meta.path when no folder is provided (root creation)', () => {
const yamlText = buildStarterYaml()
const parsed = parseFlowYaml(yamlText)
expect(parsed.success).toBe(true)
const meta = (YAML.parse(yamlText) as { meta: { path?: string } }).meta
expect(meta.path).toBeUndefined()
})

it('injects meta.path when called with a folder path', () => {
const yamlText = buildStarterYaml('billing/payments')
const parsed = parseFlowYaml(yamlText)
expect(parsed.success).toBe(true)
const meta = (YAML.parse(yamlText) as { meta: { path?: string } }).meta
expect(meta.path).toBe('billing/payments')
})

it('preserves path through nested folder creation (folder-then-flow)', () => {
// Sidebar's handleCreateAt('folder', 'billing') prompts for a name, splices
// it onto the parent path, and calls buildStarterYaml('billing/<name>').
const yamlText = buildStarterYaml('billing/refunds')
const meta = (YAML.parse(yamlText) as { meta: { path?: string } }).meta
expect(meta.path).toBe('billing/refunds')
})
})
5 changes: 4 additions & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@
"prepack": "npm run build"
},
"devDependencies": {
"@codemirror/lang-yaml": "^6.1.3",
"@eslint/js": "^9.39.4",
"@openhop/shared": "*",
"@tailwindcss/vite": "^4.2.4",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@uiw/react-codemirror": "^4.25.9",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^1.6.1",
"@xyflow/react": "^12.10.2",
Expand All @@ -48,6 +50,7 @@
"typescript": "~6.0.3",
"typescript-eslint": "^8.59.1",
"vite": "^8.0.10",
"vitest": "^1.6.1"
"vitest": "^1.6.1",
"yaml": "^2.8.4"
}
}
146 changes: 145 additions & 1 deletion packages/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import YAML from 'yaml'
import { Sidebar } from './components/Sidebar'
import { FlowCanvas } from './components/FlowCanvas'
import {
DataInspectionPanel,
InspectorToggle,
type DockSide,
} from './components/DataInspectionPanel'
import { FlowEditorModal } from './components/FlowEditorModal'
import { buildStarterYaml } from './lib/starter-yaml'
import { useFlowList, useFlowData } from './hooks/useFlowPolling'
import { useFlowMutations } from './hooks/useFlowMutations'
import type { FlowNode, FlowStep, Flow } from './types'

interface FlowNavItem {
Expand Down Expand Up @@ -40,9 +44,134 @@ function App() {
return () => window.removeEventListener('popstate', handler)
}, [])

const { flows, loading: listLoading } = useFlowList()
const { flows, loading: listLoading, reload: reloadFlows } = useFlowList()
const { flow: apiFlow, loading: flowLoading } = useFlowData(selectedFlowId)

// Editor modal state. mode='new' opens with a (path-aware) starter YAML;
// mode='edit' pre-populates from the stored flow.
const [editor, setEditor] = useState<
| { mode: 'closed' }
| { mode: 'new'; initialYaml: string }
| { mode: 'edit'; flowId: string; initialYaml: string }
>({ mode: 'closed' })
const mutations = useFlowMutations()

// Sidebar's per-folder "+" menu calls this with kind='flow'|'folder' and the
// parent folder path ('' for root). For 'folder' we prompt for a name and
// splice it into the path, so the modal opens with `meta.path: <parent>/<name>`.
const handleCreateAt = useCallback(
(kind: 'flow' | 'folder', parentPath: string) => {
mutations.reset()
let path = parentPath
if (kind === 'folder') {
const raw = window.prompt('New folder name:')
if (!raw) return
const name = raw
.trim()
.replace(/^\/+|\/+$/g, '')
.replace(/\s+/g, '-')
if (!name) return
path = parentPath ? `${parentPath}/${name}` : name
}
setEditor({ mode: 'new', initialYaml: buildStarterYaml(path || undefined) })
},
[mutations]
)

const handleEditFlow = useCallback(
async (flowId: string) => {
mutations.reset()
try {
const res = await fetch(`/api/flows/${flowId}`)
if (!res.ok) {
window.alert(`Could not load flow ${flowId} for editing (HTTP ${res.status}).`)
return
}
const data = (await res.json()) as { meta: unknown; flow: unknown }
const yamlText = YAML.stringify({ meta: data.meta, flow: data.flow })
setEditor({ mode: 'edit', flowId, initialYaml: yamlText })
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
window.alert(`Could not load flow ${flowId} for editing: ${message}`)
}
},
[mutations]
)

const handleDeleteFlow = useCallback(
async (flowId: string) => {
const target = flows.find((f) => f.id === flowId)
const label = target?.title ? `"${target.title}"` : flowId
if (!window.confirm(`Delete flow ${label}? This cannot be undone.`)) return
const err = await mutations.deleteFlow(flowId)
if (err) {
window.alert(`Failed to delete flow: ${err.message}`)
return
}
reloadFlows()
if (selectedFlowId === flowId) selectFlow(null)
},
[flows, mutations, reloadFlows, selectedFlowId, selectFlow]
)

// Delete every flow at-or-below the given folder path. The server has no
// bulk-delete endpoint (and folders are virtual — they exist only as a
// derived view of each flow's meta.path), so we iterate. Confirms with the
// count up-front; bails on the first failure.
const handleDeleteFolder = useCallback(
async (folderPath: string) => {
if (!folderPath) return // root is undeletable; UI should never call this
const targets = flows.filter(
(f) => f.path === folderPath || (f.path ?? '').startsWith(`${folderPath}/`)
)
if (targets.length === 0) {
window.alert(`Folder "${folderPath}" is empty already.`)
return
}
const msg =
targets.length === 1
? `Delete folder "${folderPath}" and the 1 flow inside? This cannot be undone.`
: `Delete folder "${folderPath}" and all ${targets.length} flows inside? This cannot be undone.`
if (!window.confirm(msg)) return

let failure: { label: string; message: string } | null = null
for (const target of targets) {
const err = await mutations.deleteFlow(target.id)
if (err) {
failure = { label: target.title || target.id, message: err.message }
break
}
}
reloadFlows()
if (failure) {
window.alert(
`Stopped after failing to delete "${failure.label}" (${failure.message}) — refresh to see what's left in the folder.`
)
return
}
if (selectedFlowId && targets.some((t) => t.id === selectedFlowId)) selectFlow(null)
},
[flows, mutations, reloadFlows, selectedFlowId, selectFlow]
)

const handleEditorSave = useCallback(
async (yamlText: string) => {
const created = await mutations.createFlow(yamlText)
if (!created) return // server error stays in mutations.error; modal renders it
reloadFlows()
// For both new + edit modes we POST; selecting the new id navigates to /flow/<id>.
// (For edit, this means a fresh id since the server creates a new flow on POST.
// Patch-ops-based in-place edit is the CLI's `openhop patch` flow — out of scope per #74.)
selectFlow(created.id)
setEditor({ mode: 'closed' })
},
[mutations, reloadFlows, selectFlow]
)

const handleEditorCancel = useCallback(() => {
setEditor({ mode: 'closed' })
}, [])

const [playing, setPlaying] = useState(false)
const [flowStack, setFlowStack] = useState<FlowNavItem[]>([])

Expand Down Expand Up @@ -232,6 +361,10 @@ function App() {
loading={listLoading}
selectedFlowId={selectedFlowId}
onSelectFlow={selectFlow}
onCreateAt={handleCreateAt}
onEditFlow={handleEditFlow}
onDeleteFlow={handleDeleteFlow}
onDeleteFolder={handleDeleteFolder}
/>

{/* Canvas + Inspector */}
Expand Down Expand Up @@ -347,6 +480,17 @@ function App() {
)}
</div>
</div>

{/* Editor modal — overlays everything when open */}
<FlowEditorModal
open={editor.mode !== 'closed'}
title={editor.mode === 'edit' ? 'Edit flow' : 'New flow'}
initialYaml={editor.mode === 'closed' ? '' : editor.initialYaml}
saving={mutations.inFlight}
serverError={mutations.error}
onSave={handleEditorSave}
onCancel={handleEditorCancel}
/>
</div>
)
}
Expand Down
Loading
Loading