create-appraisejs 0.3.1-alpha.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/package.json +1 -1
  2. package/templates/blank/.env.example +2 -2
  3. package/templates/blank/.prettierrc +13 -13
  4. package/templates/blank/cucumber.mjs +12 -1
  5. package/templates/blank/e2e/helpers/test-data.ts +10 -0
  6. package/templates/blank/next-env.d.ts +6 -6
  7. package/templates/blank/package-lock.json +2 -2
  8. package/templates/blank/package.json +1 -1
  9. package/templates/blank/packages/cucumber-runtime/package.json +2 -1
  10. package/templates/blank/packages/cucumber-runtime/src/paths.ts +22 -5
  11. package/templates/blank/packages/locator-picker-companion/package.json +2 -1
  12. package/templates/blank/prisma/dev.db +0 -0
  13. package/templates/blank/prisma/migrations/20251104113456_add_type_for_template_step_groups/migration.sql +16 -16
  14. package/templates/blank/prisma/migrations/20251104170946_add_tags_to_test_suite_and_test_case/migration.sql +27 -27
  15. package/templates/blank/prisma/migrations/20251112190024_add_cascade_delete_to_test_run_test_case/migration.sql +17 -17
  16. package/templates/blank/prisma/migrations/20251113181100_add_test_run_log/migration.sql +12 -12
  17. package/templates/blank/prisma/migrations/20251119191838_add_tag_type/migration.sql +28 -28
  18. package/templates/blank/prisma/migrations/20251121164059_add_conflict_resolution/migration.sql +12 -12
  19. package/templates/blank/prisma/migrations/20251223183400_add_report_model_to_db_schema/migration.sql +10 -10
  20. package/templates/blank/prisma/migrations/20251223183637_add_report_test_case_entity_for_storing_test_results_for_individual_test_cases/migration.sql +10 -10
  21. package/templates/blank/prisma/migrations/20251224083549_add_comprehensive_report_storage/migration.sql +108 -108
  22. package/templates/blank/prisma/migrations/20251229194422_migrate_duration_to_string/migration.sql +55 -55
  23. package/templates/blank/prisma/migrations/20251230124637_add_unique_constraint_to_test_run_name/migration.sql +27 -27
  24. package/templates/blank/prisma/migrations/20260115094436_add_dashboard_metrics/migration.sql +59 -59
  25. package/templates/blank/prisma/migrations/20260127172022_add_cascade_delete_to_step_parameters/migration.sql +34 -34
  26. package/templates/blank/prisma/migrations/20260313093000_add_report_step_screenshot_path/migration.sql +1 -1
  27. package/templates/blank/scripts/setup-env.ts +0 -0
  28. package/templates/blank/scripts/sync-test-cases.ts +60 -10
  29. package/templates/blank/src/components/diagram/flow-diagram-node-search.tsx +9 -2
  30. package/templates/blank/src/components/diagram/flow-diagram-toolbar.tsx +37 -3
  31. package/templates/blank/src/components/diagram/flow-diagram.test.tsx +225 -0
  32. package/templates/blank/src/components/diagram/use-flow-diagram-search.ts +2 -0
  33. package/templates/blank/src/components/diagram/use-flow-diagram.ts +93 -0
  34. package/templates/blank/src/lib/appraise-test-case-metadata.test.ts +78 -0
  35. package/templates/blank/src/lib/appraise-test-case-metadata.ts +220 -0
  36. package/templates/blank/src/lib/automation/automation-path-roots.test.ts +14 -0
  37. package/templates/blank/src/lib/automation/automation-path-roots.ts +10 -2
  38. package/templates/blank/src/lib/database-sync.ts +166 -15
  39. package/templates/blank/src/lib/executor/local-executor-adapter.ts +6 -2
  40. package/templates/blank/src/lib/feature-file-generator.ts +54 -10
  41. package/templates/blank/src/lib/gherkin-parser.test.ts +52 -0
  42. package/templates/blank/src/lib/gherkin-parser.ts +39 -1
  43. package/templates/blank/src/lib/sync/projected-feature-utils.ts +5 -1
  44. package/templates/blank/src/lib/sync/sync-pending-counts.test.ts +115 -0
  45. package/templates/blank/src/lib/sync/sync-pending-counts.ts +108 -13
  46. package/templates/blank/src/services/test-run/test-run-service.test.ts +10 -0
  47. package/templates/blank/src/services/test-run/test-run-service.ts +41 -1
  48. package/templates/starter/.env.example +2 -2
  49. package/templates/starter/.prettierrc +13 -13
  50. package/templates/starter/cucumber.mjs +12 -1
  51. package/templates/starter/e2e/helpers/test-data.ts +10 -0
  52. package/templates/starter/next-env.d.ts +6 -6
  53. package/templates/starter/package-lock.json +2 -2
  54. package/templates/starter/package.json +1 -1
  55. package/templates/starter/packages/cucumber-runtime/package.json +2 -1
  56. package/templates/starter/packages/cucumber-runtime/src/paths.ts +22 -5
  57. package/templates/starter/packages/locator-picker-companion/package.json +2 -1
  58. package/templates/starter/prisma/dev.db +0 -0
  59. package/templates/starter/prisma/migrations/20251104113456_add_type_for_template_step_groups/migration.sql +16 -16
  60. package/templates/starter/prisma/migrations/20251104170946_add_tags_to_test_suite_and_test_case/migration.sql +27 -27
  61. package/templates/starter/prisma/migrations/20251112190024_add_cascade_delete_to_test_run_test_case/migration.sql +17 -17
  62. package/templates/starter/prisma/migrations/20251113181100_add_test_run_log/migration.sql +12 -12
  63. package/templates/starter/prisma/migrations/20251119191838_add_tag_type/migration.sql +28 -28
  64. package/templates/starter/prisma/migrations/20251121164059_add_conflict_resolution/migration.sql +12 -12
  65. package/templates/starter/prisma/migrations/20251223183400_add_report_model_to_db_schema/migration.sql +10 -10
  66. package/templates/starter/prisma/migrations/20251223183637_add_report_test_case_entity_for_storing_test_results_for_individual_test_cases/migration.sql +10 -10
  67. package/templates/starter/prisma/migrations/20251224083549_add_comprehensive_report_storage/migration.sql +108 -108
  68. package/templates/starter/prisma/migrations/20251229194422_migrate_duration_to_string/migration.sql +55 -55
  69. package/templates/starter/prisma/migrations/20251230124637_add_unique_constraint_to_test_run_name/migration.sql +27 -27
  70. package/templates/starter/prisma/migrations/20260115094436_add_dashboard_metrics/migration.sql +59 -59
  71. package/templates/starter/prisma/migrations/20260127172022_add_cascade_delete_to_step_parameters/migration.sql +34 -34
  72. package/templates/starter/prisma/migrations/20260313093000_add_report_step_screenshot_path/migration.sql +1 -1
  73. package/templates/starter/scripts/setup-env.ts +0 -0
  74. package/templates/starter/scripts/sync-test-cases.ts +60 -10
  75. package/templates/starter/src/components/diagram/flow-diagram-node-search.tsx +9 -2
  76. package/templates/starter/src/components/diagram/flow-diagram-toolbar.tsx +37 -3
  77. package/templates/starter/src/components/diagram/flow-diagram.test.tsx +225 -0
  78. package/templates/starter/src/components/diagram/use-flow-diagram-search.ts +2 -0
  79. package/templates/starter/src/components/diagram/use-flow-diagram.ts +93 -0
  80. package/templates/starter/src/lib/appraise-test-case-metadata.test.ts +78 -0
  81. package/templates/starter/src/lib/appraise-test-case-metadata.ts +220 -0
  82. package/templates/starter/src/lib/automation/automation-path-roots.test.ts +14 -0
  83. package/templates/starter/src/lib/automation/automation-path-roots.ts +10 -2
  84. package/templates/starter/src/lib/database-sync.ts +166 -15
  85. package/templates/starter/src/lib/executor/local-executor-adapter.ts +6 -2
  86. package/templates/starter/src/lib/feature-file-generator.ts +54 -10
  87. package/templates/starter/src/lib/gherkin-parser.test.ts +52 -0
  88. package/templates/starter/src/lib/gherkin-parser.ts +39 -1
  89. package/templates/starter/src/lib/sync/projected-feature-utils.ts +5 -1
  90. package/templates/starter/src/lib/sync/sync-pending-counts.test.ts +115 -0
  91. package/templates/starter/src/lib/sync/sync-pending-counts.ts +108 -13
  92. package/templates/starter/src/services/test-run/test-run-service.test.ts +10 -0
  93. package/templates/starter/src/services/test-run/test-run-service.ts +41 -1
  94. package/dist/cli.e2e.test.d.ts +0 -2
  95. package/dist/cli.e2e.test.d.ts.map +0 -1
  96. package/dist/cli.e2e.test.js +0 -75
  97. package/dist/cli.e2e.test.js.map +0 -1
  98. package/dist/config.test.d.ts +0 -2
  99. package/dist/config.test.d.ts.map +0 -1
  100. package/dist/config.test.js +0 -65
  101. package/dist/config.test.js.map +0 -1
  102. package/dist/copy-template.test.d.ts +0 -2
  103. package/dist/copy-template.test.d.ts.map +0 -1
  104. package/dist/copy-template.test.js +0 -71
  105. package/dist/copy-template.test.js.map +0 -1
  106. package/dist/download-repo.test.d.ts +0 -2
  107. package/dist/download-repo.test.d.ts.map +0 -1
  108. package/dist/download-repo.test.js +0 -16
  109. package/dist/download-repo.test.js.map +0 -1
  110. package/dist/install.test.d.ts +0 -2
  111. package/dist/install.test.d.ts.map +0 -1
  112. package/dist/install.test.js +0 -120
  113. package/dist/install.test.js.map +0 -1
  114. package/dist/prompts.test.d.ts +0 -2
  115. package/dist/prompts.test.d.ts.map +0 -1
  116. package/dist/prompts.test.js +0 -58
  117. package/dist/prompts.test.js.map +0 -1
  118. package/templates/default/next-env.d.ts +0 -6
  119. package/templates/default/packages/locator-picker-companion/dist/cli.d.ts +0 -1
  120. package/templates/default/packages/locator-picker-companion/dist/cli.js +0 -336
  121. package/templates/default/packages/locator-picker-companion/dist/index.d.ts +0 -3
  122. package/templates/default/packages/locator-picker-companion/dist/index.js +0 -3
  123. package/templates/default/packages/locator-picker-companion/dist/injected-picker-script.d.ts +0 -1
  124. package/templates/default/packages/locator-picker-companion/dist/injected-picker-script.js +0 -660
  125. package/templates/default/packages/locator-picker-companion/dist/launcher.d.ts +0 -14
  126. package/templates/default/packages/locator-picker-companion/dist/launcher.js +0 -58
  127. package/templates/default/packages/locator-picker-companion/dist/selector-generator.d.ts +0 -6
  128. package/templates/default/packages/locator-picker-companion/dist/selector-generator.js +0 -261
  129. package/templates/default/packages/locator-picker-companion/dist/session-file.d.ts +0 -30
  130. package/templates/default/packages/locator-picker-companion/dist/session-file.js +0 -162
  131. package/templates/default/packages/locator-picker-companion/dist/types.d.ts +0 -31
  132. package/templates/default/packages/locator-picker-companion/dist/types.js +0 -1
  133. package/templates/default/prisma/dev.db +0 -0
@@ -33,6 +33,9 @@ interface TestCaseFromFS {
33
33
  modulePath: string // From folder structure
34
34
  filterTags: string[] // Scenario tags excluding @tc_...
35
35
  steps: ParsedStep[] // From scenario steps
36
+ hasAppraiseMetadata: boolean
37
+ nodes: Array<{ nodeId: string; order: number; label: string }>
38
+ flowBlocks: Array<{ id: string; name: string; order: number; nodeIds: string[] }>
36
39
  filePath: string // Feature file path
37
40
  }
38
41
 
@@ -101,7 +104,8 @@ async function scanTestCasesFromFilesystem(featuresDir: string): Promise<TestCas
101
104
 
102
105
  for (const scenario of parsedFeature.scenarios) {
103
106
  // Find identifier tag (@tc_...)
104
- const identifierTag = scenario.tags.find(tag => {
107
+ const flattenedTags = scenario.tags.flatMap(splitTagLine)
108
+ const identifierTag = flattenedTags.find(tag => {
105
109
  const tagName = tag.startsWith('@') ? tag.substring(1) : tag
106
110
  return tagName.startsWith('tc_')
107
111
  })
@@ -114,20 +118,27 @@ async function scanTestCasesFromFilesystem(featuresDir: string): Promise<TestCas
114
118
  }
115
119
 
116
120
  // Extract filter tags (all tags except identifier tag)
117
- const filterTags = scenario.tags.filter(tag => tag !== identifierTag).flatMap(tag => splitTagLine(tag))
121
+ const filterTags = flattenedTags.filter(tag => tag !== identifierTag)
118
122
 
119
123
  // Parse title and description from scenario name
120
124
  // Note: gherkin parser swaps them, so we pass both
121
- const { title, description } = parseScenarioTitle(scenario.name, scenario.description)
125
+ const parsedTitle = parseScenarioTitle(scenario.name, scenario.description)
126
+ const nodesByOrder = new Map((scenario.appraiseMetadata?.nodes ?? []).map(node => [node.order, node]))
122
127
 
123
128
  testCases.push({
124
129
  identifierTag: identifierTag.startsWith('@') ? identifierTag : `@${identifierTag}`,
125
- title,
126
- description,
130
+ title: scenario.appraiseMetadata?.title ?? parsedTitle.title,
131
+ description: scenario.appraiseMetadata?.description ?? parsedTitle.description,
127
132
  testSuiteName,
128
133
  modulePath,
129
134
  filterTags,
130
- steps: scenario.steps,
135
+ steps: scenario.steps.map(step => ({
136
+ ...step,
137
+ appraiseNode: nodesByOrder.get(step.order),
138
+ })),
139
+ hasAppraiseMetadata: scenario.appraiseMetadata != null,
140
+ nodes: scenario.appraiseMetadata?.nodes ?? [],
141
+ flowBlocks: scenario.appraiseMetadata?.flowBlocks ?? [],
131
142
  filePath: parsedFeature.filePath,
132
143
  })
133
144
  }
@@ -271,13 +282,15 @@ async function syncTestCaseSteps(
271
282
  const existingStep = existingStepsMap.get(step.order)
272
283
  const { icon } = determineStepTypeAndIcon(step.keyword)
273
284
  const gherkinStep = `${step.keyword} ${step.text}`
285
+ const label = step.appraiseNode?.label ?? step.text
274
286
 
275
287
  if (existingStep) {
288
+ const expectedFlowNodeId = step.appraiseNode?.nodeId ?? existingStep.flowNodeId
276
289
  const projectedExistingStep = projectedExistingStepsMap.get(step.order)
277
290
  const matchesProjectedState =
278
291
  projectedExistingStep != null &&
279
292
  projectedExistingStep.gherkinStep === gherkinStep &&
280
- projectedExistingStep.label === step.text &&
293
+ projectedExistingStep.label === label &&
281
294
  projectedExistingStep.icon === determineProjectedStepIcon(step.keyword) &&
282
295
  projectedExistingStep.templateStepSignature === match.signature &&
283
296
  sameResolvedParameters(projectedExistingStep.parameters, match.parameters)
@@ -288,7 +301,8 @@ async function syncTestCaseSteps(
288
301
  !matchesProjectedState &&
289
302
  (existingStep.gherkinStep !== gherkinStep ||
290
303
  existingStep.templateStepId !== match.templateStepId ||
291
- existingStep.label !== step.text ||
304
+ existingStep.flowNodeId !== expectedFlowNodeId ||
305
+ existingStep.label !== label ||
292
306
  existingStep.icon !== icon)
293
307
 
294
308
  if (needsUpdate) {
@@ -296,7 +310,8 @@ async function syncTestCaseSteps(
296
310
  where: { id: existingStep.id },
297
311
  data: {
298
312
  gherkinStep,
299
- label: step.text,
313
+ flowNodeId: expectedFlowNodeId,
314
+ label,
300
315
  templateStepId: match.templateStepId,
301
316
  icon,
302
317
  },
@@ -326,7 +341,8 @@ async function syncTestCaseSteps(
326
341
  testCaseId,
327
342
  order: step.order,
328
343
  gherkinStep,
329
- label: step.text,
344
+ flowNodeId: step.appraiseNode?.nodeId,
345
+ label,
330
346
  icon,
331
347
  templateStepId: match.templateStepId,
332
348
  },
@@ -363,6 +379,38 @@ async function syncTestCaseSteps(
363
379
  }
364
380
  }
365
381
 
382
+ async function syncTestCaseFlowBlocks(testCaseId: string, testCase: TestCaseFromFS): Promise<void> {
383
+ if (!testCase.hasAppraiseMetadata) {
384
+ return
385
+ }
386
+
387
+ const validNodeIds = new Set(testCase.nodes.map(node => node.nodeId))
388
+
389
+ await prisma.testCaseFlowBlock.deleteMany({
390
+ where: { testCaseId },
391
+ })
392
+
393
+ if (testCase.flowBlocks.length === 0) {
394
+ return
395
+ }
396
+
397
+ await prisma.testCase.update({
398
+ where: { id: testCaseId },
399
+ data: {
400
+ flowBlocks: {
401
+ create: testCase.flowBlocks.map(block => ({
402
+ id: block.id,
403
+ name: block.name,
404
+ order: block.order,
405
+ nodes: {
406
+ create: block.nodeIds.filter(nodeId => validNodeIds.has(nodeId)).map(nodeId => ({ flowNodeId: nodeId })),
407
+ },
408
+ })),
409
+ },
410
+ },
411
+ })
412
+ }
413
+
366
414
  type TemplateStepForMatch = Array<{
367
415
  id: string
368
416
  signature: string
@@ -500,6 +548,7 @@ async function upsertTestCase(
500
548
  }
501
549
 
502
550
  await syncTestCaseSteps(existingTestCase.id, testCase.steps, templateSteps, result)
551
+ await syncTestCaseFlowBlocks(existingTestCase.id, testCase)
503
552
  return
504
553
  }
505
554
 
@@ -545,6 +594,7 @@ async function upsertTestCase(
545
594
  console.log(` ➕ Created test case '${testCase.title}' (${testCase.identifierTag})`)
546
595
 
547
596
  await syncTestCaseSteps(newTestCase.id, testCase.steps, templateSteps, result)
597
+ await syncTestCaseFlowBlocks(newTestCase.id, testCase)
548
598
  }
549
599
 
550
600
  async function deleteOrphanedTestCases(fsTestCaseTags: Set<string>, result: SyncResult): Promise<void> {
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import type { RefObject } from 'react'
3
+ import type { ReactNode, RefObject } from 'react'
4
4
  import { Search, X } from 'lucide-react'
5
5
  import { AnimatePresence, LazyMotion, domAnimation } from 'motion/react'
6
6
  import * as motion from 'motion/react-m'
@@ -23,6 +23,7 @@ type FlowDiagramNodeSearchProps = {
23
23
  onSearchQueryChange: (value: string) => void
24
24
  onToggleSearch: () => void
25
25
  onSelectResult: (nodeId: string) => void
26
+ shortcutHint: ReactNode
26
27
  }
27
28
 
28
29
  export function FlowDiagramNodeSearch({
@@ -34,6 +35,7 @@ export function FlowDiagramNodeSearch({
34
35
  onSearchQueryChange,
35
36
  onToggleSearch,
36
37
  onSelectResult,
38
+ shortcutHint,
37
39
  }: FlowDiagramNodeSearchProps) {
38
40
  return (
39
41
  <LazyMotion features={domAnimation} strict>
@@ -99,7 +101,12 @@ export function FlowDiagramNodeSearch({
99
101
  {isSearchOpen ? <X /> : <Search />}
100
102
  </Button>
101
103
  </TooltipTrigger>
102
- <TooltipContent side="bottom">{isSearchOpen ? 'Close search' : 'Search nodes'}</TooltipContent>
104
+ <TooltipContent side="bottom">
105
+ <div className="flex items-center gap-2">
106
+ <span>{isSearchOpen ? 'Close search' : 'Search nodes'}</span>
107
+ {shortcutHint}
108
+ </div>
109
+ </TooltipContent>
103
110
  </Tooltip>
104
111
  </TooltipProvider>
105
112
  </div>
@@ -1,9 +1,10 @@
1
1
  'use client'
2
2
 
3
- import type { RefObject } from 'react'
3
+ import { useEffect, useState, type RefObject } from 'react'
4
4
  import { Boxes, MousePointer2, Plus } from 'lucide-react'
5
5
 
6
6
  import { Button } from '@/components/ui/button'
7
+ import { Kbd, KbdGroup } from '@/components/ui/kbd'
7
8
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
8
9
  import { FlowDiagramNodeSearch } from './flow-diagram-node-search'
9
10
  import type { FlowNodeSearchResult } from './flow-diagram-node-search'
@@ -24,6 +25,31 @@ type FlowDiagramToolbarProps = {
24
25
  onOpenAddNodeDialog: () => void
25
26
  }
26
27
 
28
+ function ShortcutHint({ shortcutKey }: { shortcutKey: string }) {
29
+ const [isMac, setIsMac] = useState(false)
30
+
31
+ useEffect(() => {
32
+ queueMicrotask(() => setIsMac(navigator.userAgent.toLowerCase().includes('mac')))
33
+ }, [])
34
+
35
+ return (
36
+ <KbdGroup>
37
+ <Kbd>{isMac ? '⌘' : 'Ctrl'}</Kbd>
38
+ <Kbd>Shift</Kbd>
39
+ <Kbd>{shortcutKey}</Kbd>
40
+ </KbdGroup>
41
+ )
42
+ }
43
+
44
+ function TooltipWithShortcut({ label, shortcutKey }: { label: string; shortcutKey: string }) {
45
+ return (
46
+ <div className="flex items-center gap-2">
47
+ <span>{label}</span>
48
+ <ShortcutHint shortcutKey={shortcutKey} />
49
+ </div>
50
+ )
51
+ }
52
+
27
53
  export function FlowDiagramToolbar({
28
54
  enableNodeSearch,
29
55
  enableNodeGrouping,
@@ -51,6 +77,7 @@ export function FlowDiagramToolbar({
51
77
  onSearchQueryChange={onSearchQueryChange}
52
78
  onToggleSearch={onToggleSearch}
53
79
  onSelectResult={onSearchResultSelect}
80
+ shortcutHint={<ShortcutHint shortcutKey="S" />}
54
81
  />
55
82
  ) : null}
56
83
  {enableNodeGrouping ? (
@@ -67,7 +94,12 @@ export function FlowDiagramToolbar({
67
94
  {isGroupingSelectionMode ? <Boxes /> : <MousePointer2 />}
68
95
  </Button>
69
96
  </TooltipTrigger>
70
- <TooltipContent side="bottom">{isGroupingSelectionMode ? 'Selection mode' : 'Create block'}</TooltipContent>
97
+ <TooltipContent side="bottom">
98
+ <TooltipWithShortcut
99
+ label={isGroupingSelectionMode ? 'Selection mode' : 'Create block'}
100
+ shortcutKey="B"
101
+ />
102
+ </TooltipContent>
71
103
  </Tooltip>
72
104
  </TooltipProvider>
73
105
  ) : null}
@@ -78,7 +110,9 @@ export function FlowDiagramToolbar({
78
110
  <Plus />
79
111
  </Button>
80
112
  </TooltipTrigger>
81
- <TooltipContent side="bottom">Add Node</TooltipContent>
113
+ <TooltipContent side="bottom">
114
+ <TooltipWithShortcut label="Add Node" shortcutKey="C" />
115
+ </TooltipContent>
82
116
  </Tooltip>
83
117
  </TooltipProvider>
84
118
  </div>
@@ -149,6 +149,32 @@ function renderFlowDiagram(enableNodeSearch = true) {
149
149
  )
150
150
  }
151
151
 
152
+ function renderInteractiveFlowDiagram() {
153
+ return render(
154
+ <FlowDiagram
155
+ {...requiredProps}
156
+ enableNodeSearch
157
+ enableNodeGrouping
158
+ nodeOrder={{
159
+ 'node-1': {
160
+ order: 1,
161
+ label: 'Open Checkout',
162
+ gherkinStep: 'Given cart page',
163
+ parameters: [],
164
+ templateStepId: 'step-1',
165
+ },
166
+ 'node-2': {
167
+ order: 2,
168
+ label: 'Submit Payment',
169
+ gherkinStep: 'When payment is submitted',
170
+ parameters: [],
171
+ templateStepId: 'step-2',
172
+ },
173
+ }}
174
+ />,
175
+ )
176
+ }
177
+
152
178
  describe('FlowDiagram node search', () => {
153
179
  beforeEach(() => {
154
180
  xyflowMocks.setCenter.mockClear()
@@ -301,6 +327,205 @@ describe('FlowDiagram node search', () => {
301
327
  })
302
328
  })
303
329
 
330
+ describe('FlowDiagram keyboard shortcuts', () => {
331
+ beforeEach(() => {
332
+ xyflowMocks.setCenter.mockClear()
333
+ xyflowMocks.updateNodeInternals.mockClear()
334
+ })
335
+
336
+ it('handles shortcuts while the flow builder page is mounted', async () => {
337
+ const user = userEvent.setup()
338
+ renderInteractiveFlowDiagram()
339
+
340
+ await user.keyboard('{Control>}{Shift>}s{/Shift}{/Control}')
341
+
342
+ expect(await screen.findByRole('textbox', { name: /search nodes/i })).toBeInTheDocument()
343
+ })
344
+
345
+ it('opens and focuses search', async () => {
346
+ const user = userEvent.setup()
347
+ renderInteractiveFlowDiagram()
348
+
349
+ await user.keyboard('{Control>}{Shift>}s{/Shift}{/Control}')
350
+
351
+ const searchInput = await screen.findByRole('textbox', { name: /search nodes/i })
352
+ await waitFor(() => {
353
+ expect(searchInput).toHaveFocus()
354
+ })
355
+ })
356
+
357
+ it('toggles search closed when the shortcut is pressed again', async () => {
358
+ const user = userEvent.setup()
359
+ renderInteractiveFlowDiagram()
360
+
361
+ await user.keyboard('{Control>}{Shift>}s{/Shift}{/Control}')
362
+ expect(await screen.findByRole('textbox', { name: /search nodes/i })).toBeInTheDocument()
363
+
364
+ await user.keyboard('{Control>}{Shift>}s{/Shift}{/Control}')
365
+
366
+ await waitFor(() => {
367
+ expect(screen.queryByRole('textbox', { name: /search nodes/i })).not.toBeInTheDocument()
368
+ })
369
+ })
370
+
371
+ it('supports macOS-style meta search shortcuts', async () => {
372
+ const user = userEvent.setup()
373
+ renderInteractiveFlowDiagram()
374
+
375
+ await user.keyboard('{Meta>}{Shift>}s{/Shift}{/Meta}')
376
+
377
+ expect(await screen.findByRole('textbox', { name: /search nodes/i })).toBeInTheDocument()
378
+ })
379
+
380
+ it('toggles block selection mode when grouping is enabled', async () => {
381
+ const user = userEvent.setup()
382
+ renderInteractiveFlowDiagram()
383
+
384
+ await user.keyboard('{Control>}{Shift>}b{/Shift}{/Control}')
385
+
386
+ expect(screen.getByRole('button', { name: /exit block selection mode/i })).toBeInTheDocument()
387
+
388
+ await user.keyboard('{Control>}{Shift>}b{/Shift}{/Control}')
389
+
390
+ expect(screen.getByRole('button', { name: /select nodes for block/i })).toBeInTheDocument()
391
+ })
392
+
393
+ it('toggles the add-node sheet', async () => {
394
+ const user = userEvent.setup()
395
+ renderInteractiveFlowDiagram()
396
+
397
+ await user.keyboard('{Control>}{Shift>}c{/Shift}{/Control}')
398
+
399
+ expect(screen.getByRole('dialog')).toHaveTextContent('Node form add')
400
+
401
+ await user.keyboard('{Control>}{Shift>}c{/Shift}{/Control}')
402
+
403
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
404
+ })
405
+
406
+ it('switches from the add-node sheet to block selection mode', async () => {
407
+ const user = userEvent.setup()
408
+ renderInteractiveFlowDiagram()
409
+
410
+ await user.click(screen.getByRole('button', { name: 'Add Node' }))
411
+ await user.keyboard('{Control>}{Shift>}b{/Shift}{/Control}')
412
+
413
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
414
+ expect(screen.getByRole('button', { name: /exit block selection mode/i })).toBeInTheDocument()
415
+ })
416
+
417
+ it('switches from search to the add-node sheet', async () => {
418
+ const user = userEvent.setup()
419
+ renderInteractiveFlowDiagram()
420
+
421
+ await user.keyboard('{Control>}{Shift>}s{/Shift}{/Control}')
422
+ expect(await screen.findByRole('textbox', { name: /search nodes/i })).toBeInTheDocument()
423
+
424
+ await user.keyboard('{Control>}{Shift>}c{/Shift}{/Control}')
425
+
426
+ await waitFor(() => {
427
+ expect(screen.queryByRole('textbox', { name: /search nodes/i })).not.toBeInTheDocument()
428
+ })
429
+ expect(screen.getByRole('dialog')).toHaveTextContent('Node form add')
430
+ })
431
+
432
+ it('switches from the add-node sheet to search', async () => {
433
+ const user = userEvent.setup()
434
+ renderInteractiveFlowDiagram()
435
+
436
+ await user.keyboard('{Control>}{Shift>}c{/Shift}{/Control}')
437
+ expect(screen.getByRole('dialog')).toHaveTextContent('Node form add')
438
+
439
+ await user.keyboard('{Control>}{Shift>}s{/Shift}{/Control}')
440
+
441
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
442
+ expect(await screen.findByRole('textbox', { name: /search nodes/i })).toBeInTheDocument()
443
+ })
444
+
445
+ it('switches from block selection mode to the add-node sheet', async () => {
446
+ const user = userEvent.setup()
447
+ renderInteractiveFlowDiagram()
448
+
449
+ await user.keyboard('{Control>}{Shift>}b{/Shift}{/Control}')
450
+ expect(screen.getByRole('button', { name: /exit block selection mode/i })).toBeInTheDocument()
451
+
452
+ await user.keyboard('{Control>}{Shift>}c{/Shift}{/Control}')
453
+
454
+ expect(screen.getByRole('button', { name: /select nodes for block/i })).toBeInTheDocument()
455
+ expect(screen.getByRole('dialog')).toHaveTextContent('Node form add')
456
+ })
457
+
458
+ it('ignores shortcuts while the block dialog is open', async () => {
459
+ const user = userEvent.setup()
460
+ render(
461
+ <FlowDiagram
462
+ {...requiredProps}
463
+ enableNodeSearch
464
+ enableNodeGrouping
465
+ nodeOrder={{
466
+ 'node-1': {
467
+ order: 1,
468
+ label: 'Open Checkout',
469
+ gherkinStep: 'Given cart page',
470
+ parameters: [],
471
+ templateStepId: 'step-1',
472
+ },
473
+ 'node-2': {
474
+ order: 2,
475
+ label: 'Submit Payment',
476
+ gherkinStep: 'When payment is submitted',
477
+ parameters: [],
478
+ templateStepId: 'step-2',
479
+ },
480
+ }}
481
+ />,
482
+ )
483
+
484
+ await user.keyboard('{Control>}{Shift>}b{/Shift}{/Control}')
485
+ await user.click(screen.getByRole('button', { name: /select all flow nodes/i }))
486
+ await user.click(screen.getByRole('button', { name: /^create block$/i }))
487
+ await user.keyboard('{Control>}{Shift>}s{/Shift}{/Control}')
488
+
489
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
490
+ expect(screen.queryByRole('textbox', { name: /search nodes/i })).not.toBeInTheDocument()
491
+ })
492
+
493
+ it('switches from focused search input to the add-node sheet', async () => {
494
+ const user = userEvent.setup()
495
+ renderInteractiveFlowDiagram()
496
+
497
+ await user.click(screen.getByRole('button', { name: /search nodes/i }))
498
+ await user.click(screen.getByRole('textbox', { name: /search nodes/i }))
499
+ await user.keyboard('{Control>}{Shift>}c{/Shift}{/Control}')
500
+
501
+ await waitFor(() => {
502
+ expect(screen.queryByRole('textbox', { name: /search nodes/i })).not.toBeInTheDocument()
503
+ })
504
+ expect(screen.getByRole('dialog')).toHaveTextContent('Node form add')
505
+ })
506
+
507
+ it('shows shortcut hints in toolbar tooltips', async () => {
508
+ const user = userEvent.setup()
509
+ renderInteractiveFlowDiagram()
510
+
511
+ await user.hover(screen.getByRole('button', { name: /search nodes/i }))
512
+ expect((await screen.findAllByText('Search nodes')).length).toBeGreaterThan(0)
513
+ expect(screen.getAllByText('Ctrl').length).toBeGreaterThan(0)
514
+ expect(screen.getAllByText('Shift').length).toBeGreaterThan(0)
515
+ expect(screen.getAllByText('S').length).toBeGreaterThan(0)
516
+
517
+ await user.unhover(screen.getByRole('button', { name: /search nodes/i }))
518
+ await user.hover(screen.getByRole('button', { name: /select nodes for block/i }))
519
+ expect((await screen.findAllByText('Create block')).length).toBeGreaterThan(0)
520
+ expect(screen.getAllByText('B').length).toBeGreaterThan(0)
521
+
522
+ await user.unhover(screen.getByRole('button', { name: /select nodes for block/i }))
523
+ await user.hover(screen.getByRole('button', { name: 'Add Node' }))
524
+ expect((await screen.findAllByText('Add Node')).length).toBeGreaterThan(0)
525
+ expect(screen.getAllByText('C').length).toBeGreaterThan(0)
526
+ })
527
+ })
528
+
304
529
  describe('FlowDiagram node grouping', () => {
305
530
  it('keeps the main add-node action available after a block exists', async () => {
306
531
  const user = userEvent.setup()
@@ -105,6 +105,8 @@ export function useFlowDiagramSearch({
105
105
  nodeSearchResults,
106
106
  shouldShowSearchSuggestions,
107
107
  setSearchQuery,
108
+ closeSearch,
109
+ openSearch,
108
110
  toggleSearch,
109
111
  handleFlowPointerDown,
110
112
  handleSearchResultClick,
@@ -31,6 +31,14 @@ import { flowDiagramHandlersRef, flowEdgeTypes, flowNodeTypes } from './flow-dia
31
31
  import { useFlowDiagramBlockGrouping } from './use-flow-diagram-block-grouping'
32
32
  import { useFlowDiagramSearch } from './use-flow-diagram-search'
33
33
 
34
+ function isEditableShortcutTarget(target: EventTarget | null) {
35
+ if (!(target instanceof HTMLElement)) {
36
+ return false
37
+ }
38
+
39
+ return Boolean(target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]'))
40
+ }
41
+
34
42
  function mergeRecordsById<T extends { id: string }>(base: T[], overrides: T[]): T[] {
35
43
  const byId = new Map<string, T>()
36
44
  for (const item of base) {
@@ -156,6 +164,7 @@ export function useFlowDiagram({
156
164
 
157
165
  const { flowBlockMembership } = grouping
158
166
  const { searchHighlightedNodeId } = search
167
+ const isBlockingFlowOverlayOpen = showEditNodeDialog || grouping.isBlockDialogOpen
159
168
 
160
169
  useEffect(() => {
161
170
  flowDiagramHandlersRef.current.onEditNode = handleEditNode
@@ -344,6 +353,90 @@ export function useFlowDiagram({
344
353
  setShowAddNodeDialog(true)
345
354
  }, [])
346
355
 
356
+ const toggleAddNodeDialog = useCallback(() => {
357
+ setPendingAddSourceNodeId(null)
358
+ setShowAddNodeDialog(current => !current)
359
+ }, [])
360
+
361
+ useEffect(() => {
362
+ const handleKeyDown = (event: KeyboardEvent) => {
363
+ if (event.defaultPrevented || !event.shiftKey || (!event.ctrlKey && !event.metaKey)) {
364
+ return
365
+ }
366
+
367
+ const key = event.key.toLowerCase()
368
+ const isFlowShortcut = key === 's' || key === 'b' || key === 'c'
369
+ if (!isFlowShortcut) {
370
+ return
371
+ }
372
+
373
+ const hasShortcutSurfaceOpen = search.isSearchOpen || showAddNodeDialog || grouping.isGroupingSelectionMode
374
+ if (isBlockingFlowOverlayOpen || (isEditableShortcutTarget(event.target) && !hasShortcutSurfaceOpen)) {
375
+ return
376
+ }
377
+
378
+ if (key === 's' && enableNodeSearch) {
379
+ event.preventDefault()
380
+ if (showAddNodeDialog) {
381
+ setShowAddNodeDialog(false)
382
+ setPendingAddSourceNodeId(null)
383
+ }
384
+ if (grouping.isGroupingSelectionMode) {
385
+ grouping.toggleGroupingSelectionMode()
386
+ }
387
+ search.toggleSearch()
388
+ return
389
+ }
390
+
391
+ if (key === 'c' && showAddNodeDialog) {
392
+ event.preventDefault()
393
+ if (search.isSearchOpen) {
394
+ search.closeSearch()
395
+ }
396
+ if (grouping.isGroupingSelectionMode) {
397
+ grouping.toggleGroupingSelectionMode()
398
+ }
399
+ toggleAddNodeDialog()
400
+ return
401
+ }
402
+
403
+ if (key === 'b' && enableNodeGrouping) {
404
+ event.preventDefault()
405
+ if (search.isSearchOpen) {
406
+ search.closeSearch()
407
+ }
408
+ if (showAddNodeDialog) {
409
+ setShowAddNodeDialog(false)
410
+ setPendingAddSourceNodeId(null)
411
+ }
412
+ grouping.toggleGroupingSelectionMode()
413
+ return
414
+ }
415
+
416
+ if (key === 'c') {
417
+ event.preventDefault()
418
+ if (search.isSearchOpen) {
419
+ search.closeSearch()
420
+ }
421
+ if (grouping.isGroupingSelectionMode) {
422
+ grouping.toggleGroupingSelectionMode()
423
+ }
424
+ toggleAddNodeDialog()
425
+ }
426
+ }
427
+
428
+ window.addEventListener('keydown', handleKeyDown, { capture: true })
429
+ return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
430
+ }, [
431
+ enableNodeGrouping,
432
+ enableNodeSearch,
433
+ grouping,
434
+ isBlockingFlowOverlayOpen,
435
+ search,
436
+ showAddNodeDialog,
437
+ toggleAddNodeDialog,
438
+ ])
439
+
347
440
  return {
348
441
  enableNodeSearch,
349
442
  enableNodeGrouping,