-
Notifications
You must be signed in to change notification settings - Fork 0
feat(web): inline YAML editor — Add/Edit modal + Delete (closes #74, partial #12) #76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
294b690
feat(web): inline YAML editor — Add/Edit modal + Delete (closes #74, …
naorsabag 62fd4b0
feat(web): per-folder "+" menu (New flow / New folder) replaces top b…
naorsabag d2bf3a8
fix(web): scope sidebar hover affordances with named Tailwind groups
naorsabag f4746e4
fix(web): make folder "+" always visible (drop hover-fade)
naorsabag 6fcaf60
fix(web): scope folder "+" hover + position to the header row, not th…
naorsabag a21a56a
feat(web): delete-folder + synthetic root row + hover emphasis on flo…
naorsabag 8a20ed4
fix(web): move flow row color/bg from inline style to classes so hove…
naorsabag cb9193d
review(web): address CodeRabbit comments on #76
naorsabag 2935d7a
review(web): cancel in-flight /api/flows fetches on reload (close sta…
naorsabag 1b3fd5b
review(web): focus management on the editor modal (closes the last Co…
naorsabag File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') | ||
| // 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') | ||
| }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.