diff --git a/src/features/test-flows/ui/TestFlows.module.scss b/src/features/test-flows/ui/TestFlows.module.scss index 1b10dd4..c31ffd6 100644 --- a/src/features/test-flows/ui/TestFlows.module.scss +++ b/src/features/test-flows/ui/TestFlows.module.scss @@ -408,6 +408,153 @@ background: color-mix(in oklab, var(--background) 68%, transparent); } +.stepsDrawerOverlay { + position: fixed; + z-index: 110; + inset: 0; + display: flex; + justify-content: flex-end; + background: color-mix(in oklab, var(--background) 56%, transparent); +} + +.stepsDrawerPanel { + --flow-design-class: var(--chart-1); + --flow-assertion: var(--chart-4); + --flow-action-hook: var(--chart-5); + --flow-group: var(--chart-2); + + display: flex; + flex-direction: column; + width: min(34rem, 100%); + height: 100%; + min-width: 0; + border-left: 1px solid var(--border); + background: var(--card); + color: var(--text-primary); + box-shadow: -18px 0 38px color-mix(in oklab, var(--background) 32%, transparent); +} + +.stepsDrawerHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border-bottom: 1px solid var(--border); + + h2, + p { + margin: 0; + } + + h2 { + margin-top: 0.2rem; + color: var(--text-primary); + font-size: 1rem; + line-height: 1.25; + } + + p { + margin-top: 0.25rem; + color: var(--text-secondary); + font-size: 0.8rem; + } +} + +.stepsDrawerEyebrow { + color: var(--text-secondary); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.stepsDrawerBody { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 1rem; +} + +.stepsDrawerList { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; +} + +.stepsDrawerEditorBlock { + overflow: visible; +} + +.stepsDrawerTransitionBlock { + display: flex; + align-items: flex-start; + gap: 0.625rem; + min-height: 0; + padding: 0.625rem 0.75rem; + border: 1px solid var(--border); + border-left: 3px solid var(--success); + border-radius: 0.375rem; + background: var(--muted); + list-style: none; +} + +.stepsDrawerStepIndex { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 1.625rem; + width: 1.625rem; + height: 1.375rem; + border-radius: 999px; + background: var(--success); + color: var(--primary-foreground); + font-size: 0.6875rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.stepsDrawerTransitionBody { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.3rem; + min-width: 0; +} + +.stepsDrawerBlockDescription { + min-width: 0; + margin: 0; + overflow: visible; + color: var(--text-primary); + line-height: 1.45; + overflow-wrap: anywhere; + white-space: normal; +} + +.stepsDrawerTransitionMeta { + color: var(--text-secondary); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 0.72rem; + line-height: 1.4; + overflow-wrap: anywhere; + white-space: normal; +} + +.stepsDrawerState { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 14rem; + color: var(--text-secondary); + font-size: 0.88rem; + text-align: center; +} + .generateModal { display: flex; flex-direction: column; @@ -526,6 +673,11 @@ flex-direction: column; } + .stepsDrawerPanel { + width: 100%; + border-left: 0; + } + .modalGridTwo { grid-template-columns: 1fr; } diff --git a/src/features/test-flows/ui/TestFlows.tsx b/src/features/test-flows/ui/TestFlows.tsx index 8b3a744..06daaa1 100644 --- a/src/features/test-flows/ui/TestFlows.tsx +++ b/src/features/test-flows/ui/TestFlows.tsx @@ -19,7 +19,12 @@ import { ContentErrorPanel } from "@shared/feedback/ContentErrorPanel"; import { ErrorBanner } from "@shared/feedback/ErrorBanner"; import { PageLoader } from "@shared/feedback/PageLoader/PageLoader"; import { Button, Card, Input, Label, Select } from "@shared/ui"; -import { useGenerateTestFlow, useRegressionCodebases, useTestFlows } from "../model/queries/useTestFlows"; +import { + useFlowEditor, + useGenerateTestFlow, + useRegressionCodebases, + useTestFlows, +} from "../model/queries/useTestFlows"; import type { GenerateTestFlowRequest, ListTestFlowsRequest, @@ -29,6 +34,7 @@ import type { } from "../model/types/test-flows.types"; import { TestFlowsFilters } from "./components/TestFlowsFilters"; import { TestFlowsHeader } from "./components/TestFlowsHeader"; +import { TestFlowStepsDrawer } from "./components/TestFlowStepsDrawer"; import { TestFlowsTable } from "./components/TestFlowsTable"; import styles from "./TestFlows.module.scss"; @@ -218,6 +224,7 @@ function TestFlows() { const [cursor, setCursor] = useState(null); const [cursorStack, setCursorStack] = useState>([]); const [generatingFlow, setGeneratingFlow] = useState(null); + const [showingFlow, setShowingFlow] = useState(null); const requestedApplicationId = searchParams.get("appId"); const requestedVersionId = searchParams.get("versionId"); @@ -315,6 +322,7 @@ function TestFlows() { const flowsQuery = useTestFlows(selectedProject?.id ?? null, activeApplication?.id ?? null, filters); const regressionCodebasesQuery = useRegressionCodebases(selectedProject?.id ?? null, activeApplication?.id ?? null); + const stepsDrawerQuery = useFlowEditor(projectId, activeApplication?.id ?? null, showingFlow?.id ?? null); const generateFlowMutation = useGenerateTestFlow(); const flows = useMemo(() => flowsQuery.data?.flows ?? [], [flowsQuery.data?.flows]); const filteredFlows = useMemo(() => { @@ -503,6 +511,7 @@ function TestFlows() { isFetching={flowsQuery.isFetching} generatingFlowId={generateFlowMutation.isPending ? generatingFlow?.id : null} onGenerate={(flow) => setGeneratingFlow(flow)} + onShow={(flow) => setShowingFlow(flow)} onEdit={(flow) => { if (!activeApplication) return; navigate( @@ -523,6 +532,17 @@ function TestFlows() { /> )} + {showingFlow ? ( + setShowingFlow(null)} + onRetry={() => void stepsDrawerQuery.refetch()} + /> + ) : null} + {generatingFlow ? ( void; + onRetry: () => void; +}; + +function kindMeta(kind: FlowEditorStepKind): { + label: string; + Icon: ComponentType<{ className?: string }>; +} { + if (kind === "design-class" || kind === "design-operation") return { label: "Design", Icon: Hexagon }; + if (kind === "action-hook") return { label: "Action-Hook", Icon: Zap }; + return { label: "Assertion", Icon: Equal }; +} + +function getTextValue(value: unknown) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function getStepDescription(step: Record) { + return ( + getTextValue(step.description) ?? + getTextValue(step.action) ?? + getTextValue(step.label) ?? + getTextValue(step.transitionId) ?? + "No description available" + ); +} + +function groupDraftSteps(draftSteps: FlowEditorDraftStep[]) { + const groups = new Map(); + + draftSteps.forEach((step) => { + const key = positionKey(step.position); + groups.set(key, [...(groups.get(key) ?? []), step]); + }); + + groups.forEach((steps, key) => { + groups.set( + key, + [...steps].sort((left, right) => left.order - right.order || left.id.localeCompare(right.id)), + ); + }); + + return groups; +} + +function ReadonlyEditorStepBlock({ step }: { step: FlowEditorDraftStep }) { + const meta = kindMeta(step.kind); + const accentClass = flowStyles[`blockAccent_${step.kind}`] ?? ""; + const selector = step.element?.selector ?? null; + + return ( +
  • + +
    + {meta.label} + + {getStepDescription(step as unknown as Record)} + + {selector ? {selector} : null} +
    +
  • + ); +} + +function TransitionStepBlock({ step }: { step: FlowEditorTransitionStep }) { + const sourceLabel = step.fromState?.label; + const targetLabel = step.toState?.label; + + return ( +
  • + {step.index} +
    +

    + {getStepDescription(step as unknown as Record)} +

    + + {sourceLabel && targetLabel + ? `${sourceLabel} -> ${targetLabel}` + : `transition #${compactId(step.transitionId)}`} + +
    +
  • + ); +} + +export function TestFlowStepsDrawer({ + flow, + detail, + loading, + error, + onClose, + onRetry, +}: TestFlowStepsDrawerProps) { + const transitionSteps = useMemo(() => detail?.transitionSteps ?? [], [detail?.transitionSteps]); + const draftSteps = useMemo(() => detail?.editorSteps ?? [], [detail?.editorSteps]); + const draftGroups = useMemo(() => groupDraftSteps(draftSteps), [draftSteps]); + const attachedDraftIds = useMemo(() => { + const ids = new Set(); + + transitionSteps.forEach((step) => { + for (const draft of draftGroups.get(`before:${step.transitionId}`) ?? []) ids.add(draft.id); + for (const draft of draftGroups.get(`after:${step.transitionId}`) ?? []) ids.add(draft.id); + }); + + return ids; + }, [draftGroups, transitionSteps]); + const orphanDrafts = useMemo( + () => draftSteps.filter((draft) => !attachedDraftIds.has(draft.id)), + [attachedDraftIds, draftSteps], + ); + const totalSteps = transitionSteps.length + draftSteps.length; + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + return ( +
    + +
    + ); +} diff --git a/src/features/test-flows/ui/components/TestFlowsTable.tsx b/src/features/test-flows/ui/components/TestFlowsTable.tsx index e36d0b1..1cac170 100644 --- a/src/features/test-flows/ui/components/TestFlowsTable.tsx +++ b/src/features/test-flows/ui/components/TestFlowsTable.tsx @@ -2,7 +2,7 @@ // Proprietary and confidential. Unauthorized use is strictly prohibited. // See LICENSE file in the project root for full license information. -import { Copy, PencilLine, RefreshCw } from "lucide-react"; +import { Copy, Eye, PencilLine, RefreshCw } from "lucide-react"; import { Badge, Button, Card, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, toast } from "@shared/ui"; import type { TestFlow } from "../../model/types/test-flows.types"; import styles from "../TestFlows.module.scss"; @@ -75,6 +75,7 @@ export function TestFlowsTable({ onNextPage, onGenerate, onEdit, + onShow, generatingFlowId, }: { flows: TestFlow[]; @@ -87,6 +88,7 @@ export function TestFlowsTable({ onNextPage: () => void; onGenerate: (flow: TestFlow) => void; onEdit: (flow: TestFlow) => void; + onShow: (flow: TestFlow) => void; generatingFlowId?: string | null; }) { const showEmptyState = flows.length === 0 && !hasPreviousPage && !hasNextPage; @@ -168,6 +170,16 @@ export function TestFlowsTable({
    +
    @@ -524,7 +697,12 @@ function UserGuides() { {contextComplete ? ( - +
    }> @@ -597,7 +775,7 @@ function UserGuides() { className={styles.terminal} title={ - coverit guide · {selectedApplication?.name} · {selectedVersion?.name} · {selectedStartState?.label}{" "} + coverit guide · {selectedApplication?.name} · {guideSourceLabel} · {selectedStartState?.label}{" "} → {selectedEndState?.label} } @@ -621,7 +799,7 @@ function UserGuides() { ) } footerLeft={`${currentLine}/${totalLines} lines`} - footerRight={`${selectedApplication?.name} · ${selectedVersion?.name}`} + footerRight={`${selectedApplication?.name} · ${guideSourceLabel}`} >
    > {terminalCommand} diff --git a/src/shared/config/queryKeys.ts b/src/shared/config/queryKeys.ts index ec04f54..42538e9 100644 --- a/src/shared/config/queryKeys.ts +++ b/src/shared/config/queryKeys.ts @@ -74,8 +74,10 @@ const userGuideKeys = { applications: (projectId: string) => [...userGuideKeys.all, "applications", projectId] as const, versions: (projectId: string, applicationId: string) => [...userGuideKeys.all, "versions", projectId, applicationId] as const, - states: (projectId: string, applicationId: string, versionId: string) => - [...userGuideKeys.all, "states", projectId, applicationId, versionId] as const, + manualSessions: (projectId: string, applicationId: string, versionIds: readonly string[]) => + [...userGuideKeys.all, "manual-sessions", projectId, applicationId, versionIds] as const, + states: (projectId: string, applicationId: string, mode: string, sourceId: string) => + [...userGuideKeys.all, "states", projectId, applicationId, mode, sourceId] as const, }; const integrationKeys = {