Skip to content

Commit 3be4af6

Browse files
committed
feat(test-cases): resolve global parameters in case flows
1 parent a46d349 commit 3be4af6

7 files changed

Lines changed: 233 additions & 25 deletions

File tree

frontend/src/locales/ar.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3161,6 +3161,9 @@ export const ar = {
31613161
iterationPreviewHint: 'تم استبدال قيم هذا الصف في نص الخطوات أدناه.',
31623162
iterationOutcome: 'نتيجة هذا التكرار',
31633163
statusDerivedFromIterations: 'يتم تعيين الحالة العامة تلقائيًا من نتائج كل تكرار أعلاه.',
3164+
globalParamsResolvedHint: 'تم استبدال المعلمات العامة لهذا المشروع بقيمها في نص الخطوات أدناه.',
3165+
globalParamsReferencedHint: 'تشير هذه الحالة إلى هذه المعلمات العامة؛ ويتم استبدالها في نص الخطوات أثناء التنفيذ.',
3166+
globalParamsEditorHint: 'أشر إليها في نص الخطوات باستخدام ‎${name}‎. انقر على العنصر لنسخه.',
31643167

31653168
// المعاملات العامة
31663169
globalParametersDescription: 'معاملات أحادية القيمة قابلة لإعادة الاستخدام لهذا المشروع (ثوابت، عناوين، أسرار).',

frontend/src/locales/en.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3259,6 +3259,9 @@ export const en = {
32593259
iterationPreviewHint: 'Step text below has this row\'s values substituted.',
32603260
iterationOutcome: 'Outcome for this iteration',
32613261
statusDerivedFromIterations: 'Overall status is set automatically from the per-iteration outcomes above.',
3262+
globalParamsResolvedHint: 'Step text below has this project\'s global parameters resolved to their values.',
3263+
globalParamsReferencedHint: 'This case references these global parameters; they are resolved into step text during a run.',
3264+
globalParamsEditorHint: 'Reference these in step text as ${name}. Click a chip to copy it.',
32623265

32633266
// Global parameters
32643267
globalParametersDescription: 'Reusable single-value parameters for this project (constants, endpoints, secrets).',

frontend/src/locales/fa.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3130,6 +3130,9 @@ export const fa = {
31303130
iterationPreviewHint: 'مقادیر این ردیف در متن گام‌های زیر جایگزین شده است.',
31313131
iterationOutcome: 'نتیجه این تکرار',
31323132
statusDerivedFromIterations: 'وضعیت کلی به‌طور خودکار از نتایج هر تکرار بالا تعیین می‌شود.',
3133+
globalParamsResolvedHint: 'پارامترهای سراسری این پروژه در متن مراحل زیر با مقادیرشان جایگزین شده‌اند.',
3134+
globalParamsReferencedHint: 'این مورد به این پارامترهای سراسری ارجاع می‌دهد؛ هنگام اجرا در متن مراحل جایگزین می‌شوند.',
3135+
globalParamsEditorHint: 'در متن مراحل با ‎${name}‎ به آن‌ها ارجاع دهید. برای کپی روی برچسب کلیک کنید.',
31333136

31343137
// پارامترهای سراسری
31353138
globalParametersDescription: 'پارامترهای تک‌مقداری قابل استفاده مجدد برای این پروژه (ثابت‌ها، آدرس‌ها، اسرار).',

frontend/src/pages/TestCaseDetail.tsx

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,16 @@ import {
1616
Play,
1717
Share2,
1818
Tag,
19+
Wrench,
1920
} from 'lucide-react';
2021
import { Badge } from '@/components/ui/badge';
2122
import { Button } from '@/components/ui/button';
2223
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
2324
import { Switch } from '@/components/ui/switch';
2425
import { useTranslation } from '@/hooks/useTranslation';
2526
import { useToast } from '@/hooks/use-toast';
26-
import { api, customFieldsAPI, datasetsAPI, sectionsAPI, testCasesAPI, testSuitesAPI, type TestDataset } from '@/lib/api';
27+
import { api, customFieldsAPI, datasetsAPI, sectionsAPI, testCasesAPI, testSuitesAPI, type TestDataset, type GlobalParameter } from '@/lib/api';
28+
import { loadProjectParameters, paramsToMap, referencedKeys, resolveParameters } from '@/utils/parameters';
2729
import { CustomFieldDefinition, CustomFieldValue, Requirement, TestCase, TestSuite } from '@/types';
2830

2931
type SectionCrumb = { id: number; name: string };
@@ -70,6 +72,7 @@ export function TestCaseDetail() {
7072
const navigate = useNavigate();
7173
const [testCase, setTestCase] = useState<TestCase | null>(null);
7274
const [dataset, setDataset] = useState<TestDataset | null>(null);
75+
const [globalParams, setGlobalParams] = useState<GlobalParameter[]>([]);
7376
const [testSuite, setTestSuite] = useState<TestSuite | null>(null);
7477
const [section, setSection] = useState<{ name: string; path: SectionCrumb[] } | null>(null);
7578
const [testSteps, setTestSteps] = useState<Array<{
@@ -334,6 +337,21 @@ export function TestCaseDetail() {
334337
return () => { cancelled = true; };
335338
}, [attachedDatasetId]);
336339

340+
// Load the project's global parameters so we can show which ones this case
341+
// references via ${name} and what they resolve to.
342+
useEffect(() => {
343+
const numericProjectId = Number(effectiveProjectId);
344+
if (!Number.isFinite(numericProjectId)) {
345+
setGlobalParams([]);
346+
return;
347+
}
348+
let cancelled = false;
349+
loadProjectParameters(numericProjectId)
350+
.then((rows) => { if (!cancelled) setGlobalParams(rows); })
351+
.catch(() => { if (!cancelled) setGlobalParams([]); });
352+
return () => { cancelled = true; };
353+
}, [effectiveProjectId]);
354+
337355
const displaySteps = useMemo(() => {
338356
const parseLegacyText = (text: string | undefined | null) =>
339357
(parseCodeFence(text || '')?.code ?? (text || ''))
@@ -362,6 +380,24 @@ export function TestCaseDetail() {
362380
return parsed;
363381
}, [isMultistepCase, testCase, testSteps]);
364382

383+
// Global parameters actually referenced by this case's text, paired with the
384+
// value they resolve to during a run.
385+
const referencedParams = useMemo(() => {
386+
if (globalParams.length === 0) return [];
387+
const text = [
388+
testCase?.preconditions,
389+
testCase?.steps,
390+
testCase?.expected_result,
391+
...displaySteps.flatMap((s) => [s.action, s.expected_result]),
392+
].filter(Boolean).join('\n');
393+
const keys = new Set(referencedKeys(text));
394+
return globalParams.filter((p) => keys.has(p.name));
395+
}, [globalParams, testCase, displaySteps]);
396+
397+
// Resolve ${name} placeholders to their global-parameter values for display.
398+
const globalMap = useMemo(() => paramsToMap(globalParams), [globalParams]);
399+
const resolve = (text: string | null | undefined): string => resolveParameters(text, globalMap);
400+
365401
const latestExecution = testRunHistory[0];
366402
const uniqueRunCount = new Set(
367403
testRunHistory.map((item) => item.test_run_id).filter((value) => value != null)
@@ -614,7 +650,7 @@ export function TestCaseDetail() {
614650
<CardContent className="pt-0">
615651
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/60">
616652
<p className="whitespace-pre-wrap wrap-break-word text-[15px] leading-7 text-slate-700 dark:text-slate-300">
617-
{testCase.preconditions || t('noPreconditions')}
653+
{testCase.preconditions ? resolve(testCase.preconditions) : t('noPreconditions')}
618654
</p>
619655
</div>
620656
</CardContent>
@@ -663,6 +699,42 @@ export function TestCaseDetail() {
663699
</Card>
664700
)}
665701

702+
{referencedParams.length > 0 && (
703+
<Card className="border-slate-200 bg-white shadow-xs dark:border-slate-800 dark:bg-slate-900">
704+
<CardHeader className="pb-3">
705+
<CardTitle className="flex flex-wrap items-center gap-2 text-base font-semibold text-slate-950 dark:text-white">
706+
<span className="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/30">
707+
<Wrench className="h-4 w-4 text-amber-600 dark:text-amber-300" />
708+
</span>
709+
{t('globalParameters')}
710+
</CardTitle>
711+
</CardHeader>
712+
<CardContent className="pt-0 space-y-2">
713+
<p className="text-xs text-slate-500 dark:text-slate-400">{t('globalParamsReferencedHint')}</p>
714+
<div className="overflow-x-auto rounded-2xl border border-slate-200 dark:border-slate-800">
715+
<table className="w-full text-sm">
716+
<thead>
717+
<tr className="bg-slate-50 dark:bg-slate-950/60">
718+
<th className="px-3 py-2 text-left font-mono text-xs text-slate-700 dark:text-slate-300">{t('name')}</th>
719+
<th className="px-3 py-2 text-left text-xs text-slate-500">{t('value')}</th>
720+
</tr>
721+
</thead>
722+
<tbody>
723+
{referencedParams.map((p) => (
724+
<tr key={p.id} className="border-t border-slate-200 dark:border-slate-800">
725+
<td className="px-3 py-2 font-mono text-xs text-slate-700 dark:text-slate-300">{`\${${p.name}}`}</td>
726+
<td className="px-3 py-2 text-slate-700 dark:text-slate-300">
727+
{p.is_encrypted ? <span className="text-slate-400">••••••</span> : p.value}
728+
</td>
729+
</tr>
730+
))}
731+
</tbody>
732+
</table>
733+
</div>
734+
</CardContent>
735+
</Card>
736+
)}
737+
666738
<Card className="border-slate-200 bg-white shadow-xs dark:border-slate-800 dark:bg-slate-900">
667739
<CardHeader className="pb-3">
668740
<CardTitle className="flex flex-wrap items-center gap-2 text-base font-semibold text-slate-950 dark:text-white">
@@ -699,13 +771,13 @@ export function TestCaseDetail() {
699771
<div>
700772
<h5 className="mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">{t('action')}</h5>
701773
<p className="whitespace-pre-wrap wrap-break-word text-sm leading-7 text-slate-600 dark:text-slate-300">
702-
{step.action || t('noStepsDefined')}
774+
{step.action ? resolve(step.action) : t('noStepsDefined')}
703775
</p>
704776
</div>
705777
<div>
706778
<h5 className="mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">{t('expectedResult')}</h5>
707779
<p className="whitespace-pre-wrap wrap-break-word text-sm leading-7 text-slate-600 dark:text-slate-300">
708-
{step.expected_result || t('noExpectedResults')}
780+
{step.expected_result ? resolve(step.expected_result) : t('noExpectedResults')}
709781
</p>
710782
</div>
711783
</div>
@@ -720,7 +792,7 @@ export function TestCaseDetail() {
720792
)
721793
) : testCase.steps ? (
722794
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/60">
723-
<StepsTextContent value={testCase.steps} />
795+
<StepsTextContent value={resolve(testCase.steps)} />
724796
</div>
725797
) : (
726798
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50/80 p-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-950/60">
@@ -745,7 +817,7 @@ export function TestCaseDetail() {
745817
{testCase.expected_result ? (
746818
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/60">
747819
<p className="whitespace-pre-wrap wrap-break-word text-sm leading-7 text-slate-700 dark:text-slate-300">
748-
{testCase.expected_result}
820+
{resolve(testCase.expected_result)}
749821
</p>
750822
</div>
751823
) : (

frontend/src/pages/TestCaseEdit.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import { ReferenceField } from '@/components/ui/reference-field';
1313
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
1414
import { ArrowLeft, Save, Trash2, Plus, AlertTriangle, RefreshCw, Loader2, Sparkles, ListChecks, Target, FileCode2, Split, ShieldAlert, Check, CopyPlus, ExternalLink, type LucideIcon } from 'lucide-react';
1515
import { ToastAction } from '@/components/ui/toast';
16-
import { aiManagerAPI, AIManagerStatus, testCasesAPI, testSuitesAPI, projectsAPI, sectionsAPI, customFieldsAPI, enumsAPI, datasetsAPI, type TestDataset } from '@/lib/api';
16+
import { aiManagerAPI, AIManagerStatus, testCasesAPI, testSuitesAPI, projectsAPI, sectionsAPI, customFieldsAPI, enumsAPI, datasetsAPI, type TestDataset, type GlobalParameter } from '@/lib/api';
17+
import { loadProjectParameters } from '@/utils/parameters';
1718
import { CustomFieldDefinition } from '@/types';
1819
import { useProjectStore } from '@/stores/projectStore';
1920
import { useToast } from '@/hooks/use-toast';
@@ -90,6 +91,7 @@ export function TestCaseEdit() {
9091
const [testTypeOptions, setTestTypeOptions] = useState<SelectOption[]>([]);
9192
const [testTypesLoading, setTestTypesLoading] = useState(false);
9293
const [datasets, setDatasets] = useState<TestDataset[]>([]);
94+
const [globalParams, setGlobalParams] = useState<GlobalParameter[]>([]);
9395
const [aiDialogOpen, setAiDialogOpen] = useState(false);
9496
const [aiAssistantAction, setAiAssistantAction] = useState<AIAssistantAction>('suggest_steps');
9597
const [aiInstructions, setAiInstructions] = useState('');
@@ -422,6 +424,19 @@ export function TestCaseEdit() {
422424
return () => { cancelled = true; };
423425
}, [currentProjectId]);
424426

427+
// Global parameters this case can reference as ${name} in its step text.
428+
useEffect(() => {
429+
if (!currentProjectId) {
430+
setGlobalParams([]);
431+
return;
432+
}
433+
let cancelled = false;
434+
loadProjectParameters(currentProjectId)
435+
.then((rows) => { if (!cancelled) setGlobalParams(rows); })
436+
.catch(() => { if (!cancelled) setGlobalParams([]); });
437+
return () => { cancelled = true; };
438+
}, [currentProjectId]);
439+
425440
const handleInputChange = (field: string, value: string | number | null) => {
426441
setFormData(prev => {
427442
const updated = {
@@ -1277,6 +1292,26 @@ export function TestCaseEdit() {
12771292
})()}
12781293
</div>
12791294

1295+
{globalParams.length > 0 && (
1296+
<div>
1297+
<Label>{t('globalParameters')}</Label>
1298+
<p className="text-xs text-muted-foreground mt-1">{t('globalParamsEditorHint')}</p>
1299+
<div className="flex flex-wrap gap-1 mt-2">
1300+
{globalParams.map((p) => (
1301+
<Badge
1302+
key={p.id}
1303+
variant="secondary"
1304+
className="font-mono text-[10px] cursor-pointer"
1305+
title={p.is_encrypted ? t('encrypted') : p.value}
1306+
onClick={() => navigator.clipboard?.writeText(`\${${p.name}}`)}
1307+
>
1308+
{`\${${p.name}}`}
1309+
</Badge>
1310+
))}
1311+
</div>
1312+
</div>
1313+
)}
1314+
12801315
<div>
12811316
<Label htmlFor="tags">{t('tags')}</Label>
12821317
<Input

0 commit comments

Comments
 (0)