diff --git a/package-lock.json b/package-lock.json index 89d4423..b736ce9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -207,6 +207,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -258,6 +267,114 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "dev": true, + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "dev": true, + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "dev": true, + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "dev": true, + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "dev": true, + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz", + "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==", + "dev": true, + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "dev": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "dev": true, + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.41.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.1.tgz", + "integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==", + "dev": true, + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -302,6 +419,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -319,6 +437,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -336,6 +455,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -353,6 +473,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -370,6 +491,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -387,6 +509,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -404,6 +527,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -421,6 +545,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -438,6 +563,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -455,6 +581,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -472,6 +599,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -489,6 +617,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -506,6 +635,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -523,6 +653,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -540,6 +671,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -557,6 +689,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -574,6 +707,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -591,6 +725,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -608,6 +743,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -625,6 +761,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -642,6 +779,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -659,6 +797,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -676,6 +815,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -693,6 +833,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -710,6 +851,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -727,6 +869,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -1253,6 +1396,41 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "dev": true + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "dev": true, + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "dev": true, + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "dev": true, + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, "node_modules/@lukeed/ms": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", @@ -1262,6 +1440,12 @@ "node": ">=8" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -2641,6 +2825,59 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.25.9", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.9.tgz", + "integrity": "sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw==", + "dev": true, + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.25.9", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.9.tgz", + "integrity": "sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.25.9", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -3137,6 +3374,21 @@ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", "dev": true }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "dev": true, + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3208,6 +3460,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5713,6 +5971,12 @@ "dev": true, "license": "MIT" }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -7159,6 +7423,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7216,7 +7486,6 @@ "version": "2.8.4", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", - "license": "ISC", "bin": { "yaml": "bin.mjs" }, @@ -8306,12 +8575,14 @@ "version": "0.1.0-beta.0", "license": "MIT", "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", @@ -8326,7 +8597,8 @@ "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" } } } diff --git a/packages/web/__tests__/flow-mutations.test.ts b/packages/web/__tests__/flow-mutations.test.ts new file mode 100644 index 0000000..714e26d --- /dev/null +++ b/packages/web/__tests__/flow-mutations.test.ts @@ -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') + // 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/'). + const yamlText = buildStarterYaml('billing/refunds') + const meta = (YAML.parse(yamlText) as { meta: { path?: string } }).meta + expect(meta.path).toBe('billing/refunds') + }) +}) diff --git a/packages/web/package.json b/packages/web/package.json index 930d535..5e6ff3e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -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", @@ -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" } } diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 046edcf..39d4305 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react' +import YAML from 'yaml' import { Sidebar } from './components/Sidebar' import { FlowCanvas } from './components/FlowCanvas' import { @@ -6,7 +7,10 @@ import { 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 { @@ -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: /`. + 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/. + // (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([]) @@ -232,6 +361,10 @@ function App() { loading={listLoading} selectedFlowId={selectedFlowId} onSelectFlow={selectFlow} + onCreateAt={handleCreateAt} + onEditFlow={handleEditFlow} + onDeleteFlow={handleDeleteFlow} + onDeleteFolder={handleDeleteFolder} /> {/* Canvas + Inspector */} @@ -347,6 +480,17 @@ function App() { )} + + {/* Editor modal — overlays everything when open */} + ) } diff --git a/packages/web/src/components/FlowEditorModal.tsx b/packages/web/src/components/FlowEditorModal.tsx new file mode 100644 index 0000000..80d9759 --- /dev/null +++ b/packages/web/src/components/FlowEditorModal.tsx @@ -0,0 +1,282 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import CodeMirror from '@uiw/react-codemirror' +import { yaml as yamlLang } from '@codemirror/lang-yaml' +import { parseFlowYaml } from '@openhop/shared' +import type { ValidationError } from '@openhop/shared' +import type { MutationError } from '../hooks/useFlowMutations' +import { STARTER_YAML } from '../lib/starter-yaml' + +export interface FlowEditorModalProps { + open: boolean + /** Pre-populated YAML for "edit" mode. Empty string ⇒ "new" mode (uses STARTER_YAML). */ + initialYaml: string + /** Header label — "New flow" / "Edit flow". */ + title: string + /** True while the parent's mutation is in flight. */ + saving: boolean + /** Surfaced from the parent's useFlowMutations() — server errors after save. */ + serverError: MutationError | null + onSave: (yamlText: string) => void + onCancel: () => void +} + +/** + * YAML editor modal. Validates locally via parseFlowYaml() (sub-100ms) so the + * "Save" button is disabled until the schema passes. Server errors land in + * serverError; we render those distinctly because they may include path + * suggestions that local validation didn't catch (e.g. node-ref existence). + */ +export function FlowEditorModal({ + open, + initialYaml, + title, + saving, + serverError, + onSave, + onCancel, +}: FlowEditorModalProps) { + const [text, setText] = useState(initialYaml || STARTER_YAML) + const onSaveRef = useRef(onSave) + const onCancelRef = useRef(onCancel) + const textRef = useRef(text) + // While `saving` is true we freeze every dismiss + save trigger: + // POST /api/flows is non-idempotent, so allowing Cmd+Enter to fire twice + // (or Esc/backdrop to "dismiss" while a save is mid-flight) would create + // duplicate flows or hidden background saves. The Save button itself is + // already gated via disabled={!canSave}; this ref carries the same gate + // into the keyboard / backdrop / Cancel paths through the closure. + const savingRef = useRef(saving) + // Refs for focus management — see the focus-trap effect below. + const dialogRef = useRef(null) + const previouslyFocusedRef = useRef(null) + + useEffect(() => { + onSaveRef.current = onSave + onCancelRef.current = onCancel + textRef.current = text + savingRef.current = saving + }) + + // Re-seed the editor whenever the modal opens with a new flow. + useEffect(() => { + if (open) setText(initialYaml || STARTER_YAML) + }, [open, initialYaml]) + + const localValidation = useMemo(() => parseFlowYaml(text), [text]) + + // Focus management: on open, save the previously-focused element and move + // focus into the dialog. On close, restore focus. Without this, keyboard + // users could Tab into the sidebar/header behind the overlay even though + // role="dialog" + aria-modal are set. + useEffect(() => { + if (!open) return + previouslyFocusedRef.current = (document.activeElement as HTMLElement | null) ?? null + // Defer to next frame so the dialog's children (CodeMirror, buttons) are + // mounted and focusable. + const t = window.setTimeout(() => { + const first = dialogRef.current?.querySelector( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + if (first) first.focus() + else dialogRef.current?.focus() + }, 0) + return () => { + window.clearTimeout(t) + previouslyFocusedRef.current?.focus?.() + } + }, [open]) + + // Keyboard handler — combines: + // - Esc: cancel (no-op while saving) + // - Cmd/Ctrl-Enter: save (no-op while saving / invalid) + // - Tab / Shift-Tab: trap focus inside the dialog + useEffect(() => { + if (!open) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Tab' && dialogRef.current) { + const focusables = Array.from( + dialogRef.current.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + ) + if (focusables.length === 0) return + const first = focusables[0] + const last = focusables[focusables.length - 1] + const active = document.activeElement + // Edge cases: if focus is somehow outside the dialog, pull it back in. + if (active && !dialogRef.current.contains(active)) { + e.preventDefault() + ;(e.shiftKey ? last : first).focus() + return + } + if (e.shiftKey && active === first) { + e.preventDefault() + last.focus() + } else if (!e.shiftKey && active === last) { + e.preventDefault() + first.focus() + } + return + } + if (savingRef.current) return + if (e.key === 'Escape') { + e.preventDefault() + onCancelRef.current() + } else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault() + if (parseFlowYaml(textRef.current).success) { + onSaveRef.current(textRef.current) + } + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [open]) + + if (!open) return null + + const validationErrors: ValidationError[] = localValidation.success ? [] : localValidation.errors + const canSave = localValidation.success && !saving + + return ( +
{ + // Backdrop click dismisses, but only when no save is in flight — see + // savingRef rationale at the top of the component. + if (!saving && e.target === e.currentTarget) onCancel() + }} + style={{ + position: 'fixed', + inset: 0, + background: 'rgba(0, 0, 0, 0.6)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 100, + }} + > +
+ {/* Header */} +
+

+ {title} +

+ +
+ + {/* Editor */} +
+ setText(v)} + extensions={[yamlLang()]} + theme="dark" + height="100%" + style={{ height: '100%', fontSize: 13 }} + basicSetup={{ lineNumbers: true, foldGutter: false, highlightActiveLine: true }} + /> +
+ + {/* Validation feedback */} +
0 || serverError ? '#ff8a8a' : '#7df9ff', + }} + data-testid="flow-editor-validation" + > + {validationErrors.length === 0 && !serverError && '✓ Valid OpenHop flow'} + {validationErrors.map((err, i) => ( +
+ {err.path || '(root)'}: {err.message} + {err.suggestion ? — {err.suggestion} : null} +
+ ))} + {serverError && + (serverError.details && serverError.details.length > 0 ? ( + serverError.details.map((d, i) => ( +
+ {d.path || '(root)'}: {d.message} + {d.suggestion ? — {d.suggestion} : null} +
+ )) + ) : ( +
+ server: {serverError.message} +
+ ))} +
+ + {/* Footer */} +
+ + ⌘/Ctrl-Enter to save · Esc to cancel + + + +
+
+
+ ) +} diff --git a/packages/web/src/components/Sidebar.tsx b/packages/web/src/components/Sidebar.tsx index ff478a9..bf16870 100644 --- a/packages/web/src/components/Sidebar.tsx +++ b/packages/web/src/components/Sidebar.tsx @@ -68,6 +68,95 @@ function filterTree(nodes: TreeNode[], query: string): TreeNode[] { return result } +interface AddMenuState { + /** Full folder path for which the menu is open. `''` represents the root. `null` = closed. */ + openForPath: string | null + setOpenForPath: (path: string | null) => void +} + +interface AddMenuProps { + parentPath: string // '' for root + onClose: () => void + onCreateAt: (kind: 'flow' | 'folder', parentPath: string) => void +} + +function AddMenu({ parentPath, onClose, onCreateAt }: AddMenuProps) { + const ref = useRef(null) + + // Close on outside click / Esc. + useEffect(() => { + const onDoc = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose() + } + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + // Defer the click listener until next tick so the click that opened the + // menu (which bubbles up to document) doesn't immediately close it. + const t = setTimeout(() => document.addEventListener('mousedown', onDoc), 0) + document.addEventListener('keydown', onKey) + return () => { + clearTimeout(t) + document.removeEventListener('mousedown', onDoc) + document.removeEventListener('keydown', onKey) + } + }, [onClose]) + + return ( +
+ + +
+ ) +} + interface TreeItemProps { node: TreeNode depth: number @@ -75,6 +164,13 @@ interface TreeItemProps { expandedFolders: Set toggleFolder: (path: string) => void onSelectFlow: (id: string) => void + onEditFlow?: (id: string) => void + onDeleteFlow?: (id: string) => void + onDeleteFolder?: (path: string) => void + onCreateAt?: (kind: 'flow' | 'folder', parentPath: string) => void + addMenu?: AddMenuState + /** Marks the synthetic top-level "/" row. Always expanded, no delete, no toggle caret. */ + isRoot?: boolean } function TreeItem({ @@ -84,21 +180,122 @@ function TreeItem({ expandedFolders, toggleFolder, onSelectFlow, + onEditFlow, + onDeleteFlow, + onDeleteFolder, + onCreateAt, + addMenu, + isRoot, }: TreeItemProps) { if (node.type === 'folder') { - const expanded = expandedFolders.has(node.path) + // Root is always expanded \u2014 collapsing the workspace serves no purpose + // and would just hide the entire tree. + const expanded = isRoot || expandedFolders.has(node.path) + const showAddBtn = !!onCreateAt + const showDeleteBtn = !isRoot && !!onDeleteFolder + const menuOpen = addMenu?.openForPath === node.path + // Wrap the header in its own .group/folder.relative so hover and absolute + // positioning scope to just the folder header row, not the entire expanded + // subtree (which includes child
    ). Without this, "top: 50%" on the + // "+" button lands at the geometric center of the whole subtree, and + // hovering a child flow row triggers the parent folder's group-hover. + const rowPaddingLeft = depth * 16 + 8 + const headerLabel = isRoot ? '/' : node.name return ( -
  • - +
  • +
    + {isRoot ? ( + // Root row \u2014 non-toggleable, slightly emphasized. +
    + + {'/'} + + {headerLabel} +
    + ) : ( + + )} +
    + {showAddBtn && ( + + )} + {showDeleteBtn && ( + + )} +
    + {menuOpen && onCreateAt && ( + addMenu?.setOpenForPath(null)} + onCreateAt={onCreateAt} + /> + )} +
    {expanded && (
      {node.children.map((child, i) => ( @@ -110,6 +307,11 @@ function TreeItem({ expandedFolders={expandedFolders} toggleFolder={toggleFolder} onSelectFlow={onSelectFlow} + onEditFlow={onEditFlow} + onDeleteFlow={onDeleteFlow} + onDeleteFolder={onDeleteFolder} + onCreateAt={onCreateAt} + addMenu={addMenu} /> ))}
    @@ -119,23 +321,79 @@ function TreeItem({ } const isActive = node.flowId === selectedFlowId + const flowId = node.flowId return ( -
  • +
  • + {flowId && (onEditFlow || onDeleteFlow) && ( +
    + {onEditFlow && ( + + )} + {onDeleteFlow && ( + + )} +
    + )}
  • ) } @@ -145,9 +403,29 @@ interface SidebarProps { loading: boolean selectedFlowId: string | null onSelectFlow: (id: string | null) => void + /** parentPath is `''` for root, or the folder path for nested creation. */ + onCreateAt?: (kind: 'flow' | 'folder', parentPath: string) => void + onEditFlow?: (id: string) => void + onDeleteFlow?: (id: string) => void + /** Delete a folder and every flow whose meta.path is at-or-below it. Not callable on root. */ + onDeleteFolder?: (path: string) => void } -export function Sidebar({ flows, loading, selectedFlowId, onSelectFlow }: SidebarProps) { +export function Sidebar({ + flows, + loading, + selectedFlowId, + onSelectFlow, + onCreateAt, + onEditFlow, + onDeleteFlow, + onDeleteFolder, +}: SidebarProps) { + const [addMenuPath, setAddMenuPath] = useState(null) + const addMenu: AddMenuState = useMemo( + () => ({ openForPath: addMenuPath, setOpenForPath: setAddMenuPath }), + [addMenuPath] + ) const [search, setSearch] = useState('') const [expandedFolders, setExpandedFolders] = useState>(() => new Set()) @@ -217,27 +495,31 @@ export function Sidebar({ flows, loading, selectedFlowId, onSelectFlow }: Sideba /> - {/* Tree */} + {/* Tree — root row is synthetic and always present so users can add at "/" */}
    {loading ? (

    Loading...

    - ) : displayTree.length === 0 ? ( -

    - {search ? 'No matches' : 'No flows yet'} -

    ) : (
      - {displayTree.map((node, i) => ( - - ))} + + {displayTree.length === 0 && ( +
    • + {search ? 'No matches' : 'No flows yet — add one with the "+" above.'} +
    • + )}
    )}
    diff --git a/packages/web/src/hooks/useFlowMutations.ts b/packages/web/src/hooks/useFlowMutations.ts new file mode 100644 index 0000000..ecbb209 --- /dev/null +++ b/packages/web/src/hooks/useFlowMutations.ts @@ -0,0 +1,104 @@ +import { useCallback, useState } from 'react' + +const API_BASE = '' // proxy handles /api -> localhost:8787 + +export interface CreateFlowResult { + id: string + title: string + version: number +} + +export interface ServerErrorDetail { + path: string + message: string + suggestion?: string +} + +export interface MutationError { + /** "validation" mirrors the CLI: server rejected the YAML. */ + kind: 'validation' | 'server' | 'network' + status?: number + message: string + details?: ServerErrorDetail[] +} + +interface MutationState { + inFlight: boolean + error: MutationError | null +} + +/** + * Mutation hook for create / delete. POSTs raw YAML so the server hits the same + * code path as `openhop push` (single source of truth for validation messages). + */ +export function useFlowMutations() { + const [state, setState] = useState({ inFlight: false, error: null }) + + const reset = useCallback(() => { + setState({ inFlight: false, error: null }) + }, []) + + const createFlow = useCallback(async (yamlText: string): Promise => { + setState({ inFlight: true, error: null }) + try { + const res = await fetch(`${API_BASE}/api/flows`, { + method: 'POST', + headers: { 'Content-Type': 'text/yaml' }, + body: yamlText, + }) + if (!res.ok) { + let details: ServerErrorDetail[] | undefined + try { + const body = (await res.json()) as { details?: ServerErrorDetail[] } + details = body.details + } catch { + /* fall through with empty details */ + } + const err: MutationError = + res.status === 400 + ? { kind: 'validation', status: 400, message: 'Server rejected the flow', details } + : { kind: 'server', status: res.status, message: `HTTP ${res.status}`, details } + setState({ inFlight: false, error: err }) + return null + } + const data = (await res.json()) as CreateFlowResult + setState({ inFlight: false, error: null }) + return data + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + setState({ inFlight: false, error: { kind: 'network', message } }) + return null + } + }, []) + + // Returns the error directly (or null on success) instead of just a bool — + // callers that `await` then immediately read `mutations.error` would get + // stale closure state because the post-await `mutations` reference still + // points at the pre-setState render. Returning the error inline avoids + // that footgun. + const deleteFlow = useCallback(async (flowId: string): Promise => { + setState({ inFlight: true, error: null }) + try { + const res = await fetch(`${API_BASE}/api/flows/${flowId}`, { method: 'DELETE' }) + if (!res.ok && res.status !== 404) { + const err: MutationError = { + kind: 'server', + status: res.status, + message: `HTTP ${res.status}`, + } + setState({ inFlight: false, error: err }) + return err + } + // 404 is treated as success — already gone. + setState({ inFlight: false, error: null }) + return null + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + const err: MutationError = { kind: 'network', message } + setState({ inFlight: false, error: err }) + return err + } + }, []) + + return { ...state, createFlow, deleteFlow, reset } +} diff --git a/packages/web/src/hooks/useFlowPolling.ts b/packages/web/src/hooks/useFlowPolling.ts index 635b12c..29386d5 100644 --- a/packages/web/src/hooks/useFlowPolling.ts +++ b/packages/web/src/hooks/useFlowPolling.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import type { Flow } from '../types' const API_BASE = '' // proxy handles /api -> localhost:8787 @@ -15,18 +15,33 @@ export interface FlowListItem { export function useFlowList() { const [flows, setFlows] = useState([]) const [loading, setLoading] = useState(true) + const [tick, setTick] = useState(0) + + // `reload()` bumps `tick`, which retriggers the fetch effect. Mutations + // (create / delete) call this to refresh the sidebar without remounting. + const reload = useCallback(() => setTick((t) => t + 1), []) useEffect(() => { - fetch(`${API_BASE}/api/flows`) + // AbortController cancels the previous in-flight fetch when `reload()` + // bumps `tick`. Without it, two overlapping /api/flows requests can + // resolve out of order and a stale response can overwrite the fresh + // post-mutation list — putting just-deleted flows back in the sidebar. + const controller = new AbortController() + fetch(`${API_BASE}/api/flows`, { signal: controller.signal }) .then((r) => r.json()) .then((data) => { setFlows(data) setLoading(false) }) - .catch(() => setLoading(false)) - }, []) + .catch((err) => { + // AbortError means a newer reload superseded us — leave state alone. + if (err instanceof DOMException && err.name === 'AbortError') return + setLoading(false) + }) + return () => controller.abort() + }, [tick]) - return { flows, loading } + return { flows, loading, reload } } export function useFlowData(flowId: string | null) { diff --git a/packages/web/src/lib/starter-yaml.ts b/packages/web/src/lib/starter-yaml.ts new file mode 100644 index 0000000..f25a163 --- /dev/null +++ b/packages/web/src/lib/starter-yaml.ts @@ -0,0 +1,44 @@ +/** + * Starter YAML for "+ New flow" / "+ New folder" actions in the sidebar. + * + * Lives in lib/ rather than next to FlowEditorModal so the modal file can + * keep a clean component-only export shape — exporting non-component + * helpers from a component file trips `react-refresh/only-export-components` + * and breaks Vite's fast refresh. + */ + +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 +` + +/** + * Build the seed YAML for a "New flow" inside a given folder path. When no + * path is supplied the flow lands at the workspace root. + * + * `\s{2}` (instead of two literal spaces) sidesteps the `no-regex-spaces` + * lint rule and reads as deliberate two-space indent matching. + */ +export function buildStarterYaml(path?: string): string { + if (!path) return STARTER_YAML + return STARTER_YAML.replace( + /^meta:\n\s{2}title: New flow\n/, + `meta:\n title: New flow\n path: ${path}\n` + ) +} + +export { STARTER_YAML } diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index f2b4371..c0f1f72 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -8,6 +8,19 @@ export default defineConfig({ server: { host: '0.0.0.0', port: 8788, + // Explicit allowlist (not `true`) — `allowedHosts: true` disables Vite's + // host-header validation entirely and opens the dev server up to DNS + // rebinding attacks (a malicious page can resolve a hostname back to the + // dev server and pull source). The defaults already cover localhost + + // every IP literal; we only need to add the docker/WSL hostnames a + // contributor might hit when running inside a container, plus an env-var + // escape hatch for tunneled hostnames (ngrok / cloudflared / etc.). + allowedHosts: [ + 'host.docker.internal', + 'openhop', + '.localhost', + ...(process.env.VITE_ADDITIONAL_ALLOWED_HOSTS?.split(',').map((s) => s.trim()) ?? []), + ], proxy: { '/api': 'http://localhost:8787', '/health': 'http://localhost:8787',