Skip to content

Commit fc2fef7

Browse files
fix(ui): query parameters on Attack Paths stuck between queries (#10306)
1 parent 628a076 commit fc2fef7

File tree

4 files changed

+142
-147
lines changed

4 files changed

+142
-147
lines changed

ui/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ All notable changes to the **Prowler UI** are documented in this file.
2222
### 🐞 Fixed
2323

2424
- Provider wizard now closes after updating credentials instead of incorrectly advancing to the Launch Scan step, which caused API errors for providers with existing scheduled scans [(#10278)](https://github.com/prowler-cloud/prowler/pull/10278)
25+
- Attack Paths query builder sending stale parameters from previous query selections due to validation schema and default values being recreated on every render [(#10306)](https://github.com/prowler-cloud/prowler/pull/10306)
2526

2627
---
2728

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,21 @@
11
"use client";
22

3-
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
3+
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
44

55
/**
66
* Loading skeleton for graph visualization
77
* Shows while graph data is being fetched and processed
88
*/
99
export const GraphLoading = () => {
1010
return (
11-
<div className="dark:bg-prowler-blue-400 flex h-96 items-center justify-center rounded-lg bg-gray-50">
12-
<div className="flex flex-col items-center gap-3">
13-
<div className="flex gap-2">
14-
<Skeleton className="h-3 w-3 rounded-full" />
15-
<Skeleton className="h-3 w-3 rounded-full" />
16-
<Skeleton className="h-3 w-3 rounded-full" />
17-
</div>
18-
<p className="text-sm text-gray-600 dark:text-gray-400">
19-
Loading Attack Paths graph...
20-
</p>
21-
</div>
11+
<div
12+
data-testid="graph-loading"
13+
className="flex min-h-[320px] flex-col items-center justify-center gap-4 text-center"
14+
>
15+
<TreeSpinner className="size-6" />
16+
<p className="text-muted-foreground text-sm">
17+
Loading Attack Paths graph...
18+
</p>
2219
</div>
2320
);
2421
};

ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx

Lines changed: 74 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { Controller, useFormContext } from "react-hook-form";
44

5+
import { Input } from "@/components/shadcn";
56
import type { AttackPathQuery } from "@/types/attack-paths";
67

78
interface QueryParametersFormProps {
@@ -21,14 +22,7 @@ export const QueryParametersForm = ({
2122
} = useFormContext();
2223

2324
if (!selectedQuery || !selectedQuery.attributes.parameters.length) {
24-
return (
25-
<div className="rounded-lg bg-blue-50 p-4 dark:bg-blue-950/20">
26-
<p className="text-sm text-blue-700 dark:text-blue-300">
27-
This query requires no parameters. Click &quot;Execute Query&quot; to
28-
proceed.
29-
</p>
30-
</div>
31-
);
25+
return null;
3226
}
3327

3428
return (
@@ -37,86 +31,82 @@ export const QueryParametersForm = ({
3731
Query Parameters
3832
</h3>
3933

40-
{selectedQuery.attributes.parameters.map((param) => (
41-
<Controller
42-
key={param.name}
43-
name={param.name}
44-
control={control}
45-
render={({ field }) => {
46-
if (param.data_type === "boolean") {
47-
return (
48-
<div className="flex flex-col gap-2">
49-
<label className="flex cursor-pointer items-center gap-3">
50-
<input
51-
type="checkbox"
52-
id={param.name}
53-
checked={field.value === true || field.value === "true"}
54-
onChange={(e) => field.onChange(e.target.checked)}
55-
aria-label={param.label}
56-
className="border-border-neutral-secondary bg-bg-neutral-primary text-text-primary focus:ring-primary dark:border-border-neutral-secondary dark:bg-bg-neutral-primary dark:text-text-primary h-4 w-4 rounded border focus:ring-2"
57-
/>
58-
<div className="flex flex-col gap-1">
59-
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
60-
{param.label}
61-
</span>
62-
{param.description && (
63-
<span className="text-xs text-gray-600 dark:text-gray-400">
64-
{param.description}
34+
<div
35+
data-testid="query-parameters-grid"
36+
className="grid grid-cols-1 gap-4 md:grid-cols-2"
37+
>
38+
{selectedQuery.attributes.parameters.map((param) => (
39+
<Controller
40+
key={param.name}
41+
name={param.name}
42+
control={control}
43+
render={({ field }) => {
44+
if (param.data_type === "boolean") {
45+
return (
46+
<div className="flex flex-col gap-2">
47+
<label className="flex cursor-pointer items-center gap-3">
48+
<input
49+
type="checkbox"
50+
id={param.name}
51+
checked={field.value === true || field.value === "true"}
52+
onChange={(e) => field.onChange(e.target.checked)}
53+
aria-label={param.label}
54+
className="border-border-neutral-secondary bg-bg-neutral-primary text-text-primary focus:ring-primary dark:border-border-neutral-secondary dark:bg-bg-neutral-primary dark:text-text-primary h-4 w-4 rounded border focus:ring-2"
55+
/>
56+
<div className="flex flex-col gap-1">
57+
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
58+
{param.label}
6559
</span>
66-
)}
67-
</div>
68-
</label>
69-
</div>
70-
);
71-
}
72-
73-
const errorMessage = (() => {
74-
const error = errors[param.name];
75-
if (error && typeof error.message === "string") {
76-
return error.message;
60+
{param.description && (
61+
<span className="text-xs text-gray-600 dark:text-gray-400">
62+
{param.description}
63+
</span>
64+
)}
65+
</div>
66+
</label>
67+
</div>
68+
);
7769
}
78-
return undefined;
79-
})();
8070

81-
const descriptionId = `${param.name}-description`;
82-
return (
83-
<div className="flex flex-col gap-2">
84-
<label
85-
htmlFor={param.name}
86-
className="text-sm font-medium text-gray-700 dark:text-gray-300"
87-
>
88-
{param.label}
89-
{param.required && <span className="text-red-500"> *</span>}
90-
</label>
91-
<input
92-
{...field}
93-
id={param.name}
94-
type={param.data_type === "number" ? "number" : "text"}
95-
placeholder={
96-
param.placeholder || `Enter ${param.label.toLowerCase()}`
97-
}
98-
value={field.value ?? ""}
99-
aria-describedby={
100-
param.description ? descriptionId : undefined
101-
}
102-
className="border-border-neutral-secondary bg-bg-neutral-primary text-text-neutral-primary placeholder-text-neutral-secondary focus:border-border-primary focus:ring-primary dark:border-border-neutral-secondary dark:bg-bg-neutral-primary dark:text-text-neutral-primary dark:placeholder-text-neutral-secondary dark:focus:border-border-primary rounded-md border px-3 py-2 text-sm focus:ring-1 focus:outline-none"
103-
/>
104-
{param.description && (
105-
<span
106-
id={descriptionId}
107-
className="text-xs text-gray-600 dark:text-gray-400"
71+
const errorMessage = (() => {
72+
const error = errors[param.name];
73+
if (error && typeof error.message === "string") {
74+
return error.message;
75+
}
76+
return undefined;
77+
})();
78+
79+
return (
80+
<div className="flex flex-col gap-1.5">
81+
<label
82+
htmlFor={param.name}
83+
className="text-text-neutral-tertiary text-xs font-medium"
10884
>
109-
{param.description}
110-
</span>
111-
)}
112-
{errorMessage && (
113-
<span className="text-xs text-red-500">{errorMessage}</span>
114-
)}
115-
</div>
116-
);
117-
}}
118-
/>
119-
))}
85+
{param.label}
86+
{param.required && (
87+
<span className="text-text-error-primary">*</span>
88+
)}
89+
</label>
90+
<Input
91+
{...field}
92+
id={param.name}
93+
type={param.data_type === "number" ? "number" : "text"}
94+
placeholder={
95+
param.description ||
96+
param.placeholder ||
97+
`Enter ${param.label.toLowerCase()}`
98+
}
99+
value={field.value ?? ""}
100+
/>
101+
{errorMessage && (
102+
<span className="text-xs text-red-500">{errorMessage}</span>
103+
)}
104+
</div>
105+
);
106+
}}
107+
/>
108+
))}
109+
</div>
120110
</div>
121111
);
122112
};

ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-query-builder.ts

Lines changed: 58 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -7,79 +7,86 @@ import { z } from "zod";
77

88
import type { AttackPathQuery } from "@/types/attack-paths";
99

10+
const getValidationSchema = (query?: AttackPathQuery) => {
11+
const schemaObject: Record<string, z.ZodTypeAny> = {};
12+
13+
query?.attributes.parameters.forEach((param) => {
14+
let fieldSchema: z.ZodTypeAny = z
15+
.string()
16+
.min(1, `${param.label} is required`);
17+
18+
if (param.data_type === "number") {
19+
fieldSchema = z.coerce.number().refine((val) => val >= 0, {
20+
message: `${param.label} must be a non-negative number`,
21+
});
22+
} else if (param.data_type === "boolean") {
23+
fieldSchema = z.boolean().default(false);
24+
}
25+
26+
schemaObject[param.name] = fieldSchema;
27+
});
28+
29+
return z.object(schemaObject);
30+
};
31+
32+
const getDefaultValues = (query?: AttackPathQuery) => {
33+
const defaults: Record<string, unknown> = {};
34+
35+
query?.attributes.parameters.forEach((param) => {
36+
defaults[param.name] = param.data_type === "boolean" ? false : "";
37+
});
38+
39+
return defaults;
40+
};
41+
1042
/**
1143
* Custom hook for managing query builder form state
1244
* Handles query selection, parameter validation, and form submission
1345
*/
1446
export const useQueryBuilder = (availableQueries: AttackPathQuery[]) => {
1547
const [selectedQuery, setSelectedQuery] = useState<string | null>(null);
1648

17-
// Generate dynamic Zod schema based on selected query parameters
18-
const getValidationSchema = (queryId: string | null) => {
19-
const schemaObject: Record<string, z.ZodTypeAny> = {};
20-
21-
if (queryId) {
22-
const query = availableQueries.find((q) => q.id === queryId);
23-
24-
if (query) {
25-
query.attributes.parameters.forEach((param) => {
26-
let fieldSchema: z.ZodTypeAny = z
27-
.string()
28-
.min(1, `${param.label} is required`);
29-
30-
if (param.data_type === "number") {
31-
fieldSchema = z.coerce.number().refine((val) => val >= 0, {
32-
message: `${param.label} must be a non-negative number`,
33-
});
34-
} else if (param.data_type === "boolean") {
35-
fieldSchema = z.boolean().default(false);
36-
}
37-
38-
schemaObject[param.name] = fieldSchema;
39-
});
40-
}
41-
}
42-
43-
return z.object(schemaObject);
44-
};
45-
46-
const getDefaultValues = (queryId: string | null) => {
47-
const defaults: Record<string, unknown> = {};
48-
49-
const query = availableQueries.find((q) => q.id === queryId);
50-
if (query) {
51-
query.attributes.parameters.forEach((param) => {
52-
defaults[param.name] = param.data_type === "boolean" ? false : "";
53-
});
54-
}
55-
56-
return defaults;
57-
};
49+
const getQueryById = (queryId: string | null) =>
50+
availableQueries.find((query) => query.id === queryId);
51+
const selectedQueryData = getQueryById(selectedQuery);
5852

5953
const form = useForm({
60-
resolver: zodResolver(getValidationSchema(selectedQuery)),
54+
resolver: zodResolver(getValidationSchema(selectedQueryData)),
6155
mode: "onChange",
62-
defaultValues: getDefaultValues(selectedQuery),
56+
defaultValues: getDefaultValues(selectedQueryData),
57+
shouldUnregister: true,
6358
});
6459

6560
// Update form when selectedQuery changes
6661
useEffect(() => {
67-
form.reset(getDefaultValues(selectedQuery), {
62+
form.reset(getDefaultValues(selectedQueryData), {
6863
keepDirtyValues: false,
6964
});
70-
}, [selectedQuery]); // eslint-disable-line react-hooks/exhaustive-deps
71-
72-
const selectedQueryData = availableQueries.find(
73-
(q) => q.id === selectedQuery,
74-
);
65+
}, [form, selectedQueryData]);
7566

7667
const handleQueryChange = (queryId: string) => {
7768
setSelectedQuery(queryId);
78-
form.reset();
7969
};
8070

8171
const getQueryParameters = () => {
82-
return form.getValues();
72+
if (!selectedQueryData?.attributes.parameters.length) {
73+
return undefined;
74+
}
75+
76+
const values = form.getValues() as Record<
77+
string,
78+
string | number | boolean
79+
>;
80+
81+
return selectedQueryData.attributes.parameters.reduce<
82+
Record<string, string | number | boolean>
83+
>((parameters, parameter) => {
84+
const value = values[parameter.name];
85+
if (value !== undefined) {
86+
parameters[parameter.name] = value;
87+
}
88+
return parameters;
89+
}, {});
8390
};
8491

8592
const isFormValid = () => {

0 commit comments

Comments
 (0)