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.
- package/package.json +1 -1
- package/templates/blank/.env.example +2 -2
- package/templates/blank/.prettierrc +13 -13
- package/templates/blank/cucumber.mjs +12 -1
- package/templates/blank/e2e/helpers/test-data.ts +10 -0
- package/templates/blank/next-env.d.ts +6 -6
- package/templates/blank/package-lock.json +2 -2
- package/templates/blank/package.json +1 -1
- package/templates/blank/packages/cucumber-runtime/package.json +2 -1
- package/templates/blank/packages/cucumber-runtime/src/paths.ts +22 -5
- package/templates/blank/packages/locator-picker-companion/package.json +2 -1
- package/templates/blank/prisma/dev.db +0 -0
- package/templates/blank/prisma/migrations/20251104113456_add_type_for_template_step_groups/migration.sql +16 -16
- package/templates/blank/prisma/migrations/20251104170946_add_tags_to_test_suite_and_test_case/migration.sql +27 -27
- package/templates/blank/prisma/migrations/20251112190024_add_cascade_delete_to_test_run_test_case/migration.sql +17 -17
- package/templates/blank/prisma/migrations/20251113181100_add_test_run_log/migration.sql +12 -12
- package/templates/blank/prisma/migrations/20251119191838_add_tag_type/migration.sql +28 -28
- package/templates/blank/prisma/migrations/20251121164059_add_conflict_resolution/migration.sql +12 -12
- package/templates/blank/prisma/migrations/20251223183400_add_report_model_to_db_schema/migration.sql +10 -10
- package/templates/blank/prisma/migrations/20251223183637_add_report_test_case_entity_for_storing_test_results_for_individual_test_cases/migration.sql +10 -10
- package/templates/blank/prisma/migrations/20251224083549_add_comprehensive_report_storage/migration.sql +108 -108
- package/templates/blank/prisma/migrations/20251229194422_migrate_duration_to_string/migration.sql +55 -55
- package/templates/blank/prisma/migrations/20251230124637_add_unique_constraint_to_test_run_name/migration.sql +27 -27
- package/templates/blank/prisma/migrations/20260115094436_add_dashboard_metrics/migration.sql +59 -59
- package/templates/blank/prisma/migrations/20260127172022_add_cascade_delete_to_step_parameters/migration.sql +34 -34
- package/templates/blank/prisma/migrations/20260313093000_add_report_step_screenshot_path/migration.sql +1 -1
- package/templates/blank/scripts/setup-env.ts +0 -0
- package/templates/blank/scripts/sync-test-cases.ts +60 -10
- package/templates/blank/src/components/diagram/flow-diagram-node-search.tsx +9 -2
- package/templates/blank/src/components/diagram/flow-diagram-toolbar.tsx +37 -3
- package/templates/blank/src/components/diagram/flow-diagram.test.tsx +225 -0
- package/templates/blank/src/components/diagram/use-flow-diagram-search.ts +2 -0
- package/templates/blank/src/components/diagram/use-flow-diagram.ts +93 -0
- package/templates/blank/src/lib/appraise-test-case-metadata.test.ts +78 -0
- package/templates/blank/src/lib/appraise-test-case-metadata.ts +220 -0
- package/templates/blank/src/lib/automation/automation-path-roots.test.ts +14 -0
- package/templates/blank/src/lib/automation/automation-path-roots.ts +10 -2
- package/templates/blank/src/lib/database-sync.ts +166 -15
- package/templates/blank/src/lib/executor/local-executor-adapter.ts +6 -2
- package/templates/blank/src/lib/feature-file-generator.ts +54 -10
- package/templates/blank/src/lib/gherkin-parser.test.ts +52 -0
- package/templates/blank/src/lib/gherkin-parser.ts +39 -1
- package/templates/blank/src/lib/sync/projected-feature-utils.ts +5 -1
- package/templates/blank/src/lib/sync/sync-pending-counts.test.ts +115 -0
- package/templates/blank/src/lib/sync/sync-pending-counts.ts +108 -13
- package/templates/blank/src/services/test-run/test-run-service.test.ts +10 -0
- package/templates/blank/src/services/test-run/test-run-service.ts +41 -1
- package/templates/starter/.env.example +2 -2
- package/templates/starter/.prettierrc +13 -13
- package/templates/starter/cucumber.mjs +12 -1
- package/templates/starter/e2e/helpers/test-data.ts +10 -0
- package/templates/starter/next-env.d.ts +6 -6
- package/templates/starter/package-lock.json +2 -2
- package/templates/starter/package.json +1 -1
- package/templates/starter/packages/cucumber-runtime/package.json +2 -1
- package/templates/starter/packages/cucumber-runtime/src/paths.ts +22 -5
- package/templates/starter/packages/locator-picker-companion/package.json +2 -1
- package/templates/starter/prisma/dev.db +0 -0
- package/templates/starter/prisma/migrations/20251104113456_add_type_for_template_step_groups/migration.sql +16 -16
- package/templates/starter/prisma/migrations/20251104170946_add_tags_to_test_suite_and_test_case/migration.sql +27 -27
- package/templates/starter/prisma/migrations/20251112190024_add_cascade_delete_to_test_run_test_case/migration.sql +17 -17
- package/templates/starter/prisma/migrations/20251113181100_add_test_run_log/migration.sql +12 -12
- package/templates/starter/prisma/migrations/20251119191838_add_tag_type/migration.sql +28 -28
- package/templates/starter/prisma/migrations/20251121164059_add_conflict_resolution/migration.sql +12 -12
- package/templates/starter/prisma/migrations/20251223183400_add_report_model_to_db_schema/migration.sql +10 -10
- package/templates/starter/prisma/migrations/20251223183637_add_report_test_case_entity_for_storing_test_results_for_individual_test_cases/migration.sql +10 -10
- package/templates/starter/prisma/migrations/20251224083549_add_comprehensive_report_storage/migration.sql +108 -108
- package/templates/starter/prisma/migrations/20251229194422_migrate_duration_to_string/migration.sql +55 -55
- package/templates/starter/prisma/migrations/20251230124637_add_unique_constraint_to_test_run_name/migration.sql +27 -27
- package/templates/starter/prisma/migrations/20260115094436_add_dashboard_metrics/migration.sql +59 -59
- package/templates/starter/prisma/migrations/20260127172022_add_cascade_delete_to_step_parameters/migration.sql +34 -34
- package/templates/starter/prisma/migrations/20260313093000_add_report_step_screenshot_path/migration.sql +1 -1
- package/templates/starter/scripts/setup-env.ts +0 -0
- package/templates/starter/scripts/sync-test-cases.ts +60 -10
- package/templates/starter/src/components/diagram/flow-diagram-node-search.tsx +9 -2
- package/templates/starter/src/components/diagram/flow-diagram-toolbar.tsx +37 -3
- package/templates/starter/src/components/diagram/flow-diagram.test.tsx +225 -0
- package/templates/starter/src/components/diagram/use-flow-diagram-search.ts +2 -0
- package/templates/starter/src/components/diagram/use-flow-diagram.ts +93 -0
- package/templates/starter/src/lib/appraise-test-case-metadata.test.ts +78 -0
- package/templates/starter/src/lib/appraise-test-case-metadata.ts +220 -0
- package/templates/starter/src/lib/automation/automation-path-roots.test.ts +14 -0
- package/templates/starter/src/lib/automation/automation-path-roots.ts +10 -2
- package/templates/starter/src/lib/database-sync.ts +166 -15
- package/templates/starter/src/lib/executor/local-executor-adapter.ts +6 -2
- package/templates/starter/src/lib/feature-file-generator.ts +54 -10
- package/templates/starter/src/lib/gherkin-parser.test.ts +52 -0
- package/templates/starter/src/lib/gherkin-parser.ts +39 -1
- package/templates/starter/src/lib/sync/projected-feature-utils.ts +5 -1
- package/templates/starter/src/lib/sync/sync-pending-counts.test.ts +115 -0
- package/templates/starter/src/lib/sync/sync-pending-counts.ts +108 -13
- package/templates/starter/src/services/test-run/test-run-service.test.ts +10 -0
- package/templates/starter/src/services/test-run/test-run-service.ts +41 -1
- package/dist/cli.e2e.test.d.ts +0 -2
- package/dist/cli.e2e.test.d.ts.map +0 -1
- package/dist/cli.e2e.test.js +0 -75
- package/dist/cli.e2e.test.js.map +0 -1
- package/dist/config.test.d.ts +0 -2
- package/dist/config.test.d.ts.map +0 -1
- package/dist/config.test.js +0 -65
- package/dist/config.test.js.map +0 -1
- package/dist/copy-template.test.d.ts +0 -2
- package/dist/copy-template.test.d.ts.map +0 -1
- package/dist/copy-template.test.js +0 -71
- package/dist/copy-template.test.js.map +0 -1
- package/dist/download-repo.test.d.ts +0 -2
- package/dist/download-repo.test.d.ts.map +0 -1
- package/dist/download-repo.test.js +0 -16
- package/dist/download-repo.test.js.map +0 -1
- package/dist/install.test.d.ts +0 -2
- package/dist/install.test.d.ts.map +0 -1
- package/dist/install.test.js +0 -120
- package/dist/install.test.js.map +0 -1
- package/dist/prompts.test.d.ts +0 -2
- package/dist/prompts.test.d.ts.map +0 -1
- package/dist/prompts.test.js +0 -58
- package/dist/prompts.test.js.map +0 -1
- package/templates/default/next-env.d.ts +0 -6
- package/templates/default/packages/locator-picker-companion/dist/cli.d.ts +0 -1
- package/templates/default/packages/locator-picker-companion/dist/cli.js +0 -336
- package/templates/default/packages/locator-picker-companion/dist/index.d.ts +0 -3
- package/templates/default/packages/locator-picker-companion/dist/index.js +0 -3
- package/templates/default/packages/locator-picker-companion/dist/injected-picker-script.d.ts +0 -1
- package/templates/default/packages/locator-picker-companion/dist/injected-picker-script.js +0 -660
- package/templates/default/packages/locator-picker-companion/dist/launcher.d.ts +0 -14
- package/templates/default/packages/locator-picker-companion/dist/launcher.js +0 -58
- package/templates/default/packages/locator-picker-companion/dist/selector-generator.d.ts +0 -6
- package/templates/default/packages/locator-picker-companion/dist/selector-generator.js +0 -261
- package/templates/default/packages/locator-picker-companion/dist/session-file.d.ts +0 -30
- package/templates/default/packages/locator-picker-companion/dist/session-file.js +0 -162
- package/templates/default/packages/locator-picker-companion/dist/types.d.ts +0 -31
- package/templates/default/packages/locator-picker-companion/dist/types.js +0 -1
- 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
|
|
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 =
|
|
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
|
|
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 ===
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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">
|
|
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
|
|
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">
|
|
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">
|
|
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()
|
|
@@ -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,
|