fix(ui): query parameters on Attack Paths stuck between queries (#10306)

This commit is contained in:
Alejandro Bailo
2026-03-12 09:58:46 +01:00
committed by GitHub
parent 628a076118
commit fc2fef755a
4 changed files with 142 additions and 147 deletions

View File

@@ -22,6 +22,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🐞 Fixed
- 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)
- 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)
---

View File

@@ -1,6 +1,6 @@
"use client";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
/**
* Loading skeleton for graph visualization
@@ -8,17 +8,14 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
*/
export const GraphLoading = () => {
return (
<div className="dark:bg-prowler-blue-400 flex h-96 items-center justify-center rounded-lg bg-gray-50">
<div className="flex flex-col items-center gap-3">
<div className="flex gap-2">
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 w-3 rounded-full" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
Loading Attack Paths graph...
</p>
</div>
<div
data-testid="graph-loading"
className="flex min-h-[320px] flex-col items-center justify-center gap-4 text-center"
>
<TreeSpinner className="size-6" />
<p className="text-muted-foreground text-sm">
Loading Attack Paths graph...
</p>
</div>
);
};

View File

@@ -2,6 +2,7 @@
import { Controller, useFormContext } from "react-hook-form";
import { Input } from "@/components/shadcn";
import type { AttackPathQuery } from "@/types/attack-paths";
interface QueryParametersFormProps {
@@ -21,14 +22,7 @@ export const QueryParametersForm = ({
} = useFormContext();
if (!selectedQuery || !selectedQuery.attributes.parameters.length) {
return (
<div className="rounded-lg bg-blue-50 p-4 dark:bg-blue-950/20">
<p className="text-sm text-blue-700 dark:text-blue-300">
This query requires no parameters. Click &quot;Execute Query&quot; to
proceed.
</p>
</div>
);
return null;
}
return (
@@ -37,86 +31,82 @@ export const QueryParametersForm = ({
Query Parameters
</h3>
{selectedQuery.attributes.parameters.map((param) => (
<Controller
key={param.name}
name={param.name}
control={control}
render={({ field }) => {
if (param.data_type === "boolean") {
return (
<div className="flex flex-col gap-2">
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
id={param.name}
checked={field.value === true || field.value === "true"}
onChange={(e) => field.onChange(e.target.checked)}
aria-label={param.label}
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"
/>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{param.label}
</span>
{param.description && (
<span className="text-xs text-gray-600 dark:text-gray-400">
{param.description}
<div
data-testid="query-parameters-grid"
className="grid grid-cols-1 gap-4 md:grid-cols-2"
>
{selectedQuery.attributes.parameters.map((param) => (
<Controller
key={param.name}
name={param.name}
control={control}
render={({ field }) => {
if (param.data_type === "boolean") {
return (
<div className="flex flex-col gap-2">
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
id={param.name}
checked={field.value === true || field.value === "true"}
onChange={(e) => field.onChange(e.target.checked)}
aria-label={param.label}
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"
/>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{param.label}
</span>
)}
</div>
{param.description && (
<span className="text-xs text-gray-600 dark:text-gray-400">
{param.description}
</span>
)}
</div>
</label>
</div>
);
}
const errorMessage = (() => {
const error = errors[param.name];
if (error && typeof error.message === "string") {
return error.message;
}
return undefined;
})();
return (
<div className="flex flex-col gap-1.5">
<label
htmlFor={param.name}
className="text-text-neutral-tertiary text-xs font-medium"
>
{param.label}
{param.required && (
<span className="text-text-error-primary">*</span>
)}
</label>
<Input
{...field}
id={param.name}
type={param.data_type === "number" ? "number" : "text"}
placeholder={
param.description ||
param.placeholder ||
`Enter ${param.label.toLowerCase()}`
}
value={field.value ?? ""}
/>
{errorMessage && (
<span className="text-xs text-red-500">{errorMessage}</span>
)}
</div>
);
}
const errorMessage = (() => {
const error = errors[param.name];
if (error && typeof error.message === "string") {
return error.message;
}
return undefined;
})();
const descriptionId = `${param.name}-description`;
return (
<div className="flex flex-col gap-2">
<label
htmlFor={param.name}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{param.label}
{param.required && <span className="text-red-500"> *</span>}
</label>
<input
{...field}
id={param.name}
type={param.data_type === "number" ? "number" : "text"}
placeholder={
param.placeholder || `Enter ${param.label.toLowerCase()}`
}
value={field.value ?? ""}
aria-describedby={
param.description ? descriptionId : undefined
}
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"
/>
{param.description && (
<span
id={descriptionId}
className="text-xs text-gray-600 dark:text-gray-400"
>
{param.description}
</span>
)}
{errorMessage && (
<span className="text-xs text-red-500">{errorMessage}</span>
)}
</div>
);
}}
/>
))}
}}
/>
))}
</div>
</div>
);
};

View File

@@ -7,6 +7,38 @@ import { z } from "zod";
import type { AttackPathQuery } from "@/types/attack-paths";
const getValidationSchema = (query?: AttackPathQuery) => {
const schemaObject: Record<string, z.ZodTypeAny> = {};
query?.attributes.parameters.forEach((param) => {
let fieldSchema: z.ZodTypeAny = z
.string()
.min(1, `${param.label} is required`);
if (param.data_type === "number") {
fieldSchema = z.coerce.number().refine((val) => val >= 0, {
message: `${param.label} must be a non-negative number`,
});
} else if (param.data_type === "boolean") {
fieldSchema = z.boolean().default(false);
}
schemaObject[param.name] = fieldSchema;
});
return z.object(schemaObject);
};
const getDefaultValues = (query?: AttackPathQuery) => {
const defaults: Record<string, unknown> = {};
query?.attributes.parameters.forEach((param) => {
defaults[param.name] = param.data_type === "boolean" ? false : "";
});
return defaults;
};
/**
* Custom hook for managing query builder form state
* Handles query selection, parameter validation, and form submission
@@ -14,72 +46,47 @@ import type { AttackPathQuery } from "@/types/attack-paths";
export const useQueryBuilder = (availableQueries: AttackPathQuery[]) => {
const [selectedQuery, setSelectedQuery] = useState<string | null>(null);
// Generate dynamic Zod schema based on selected query parameters
const getValidationSchema = (queryId: string | null) => {
const schemaObject: Record<string, z.ZodTypeAny> = {};
if (queryId) {
const query = availableQueries.find((q) => q.id === queryId);
if (query) {
query.attributes.parameters.forEach((param) => {
let fieldSchema: z.ZodTypeAny = z
.string()
.min(1, `${param.label} is required`);
if (param.data_type === "number") {
fieldSchema = z.coerce.number().refine((val) => val >= 0, {
message: `${param.label} must be a non-negative number`,
});
} else if (param.data_type === "boolean") {
fieldSchema = z.boolean().default(false);
}
schemaObject[param.name] = fieldSchema;
});
}
}
return z.object(schemaObject);
};
const getDefaultValues = (queryId: string | null) => {
const defaults: Record<string, unknown> = {};
const query = availableQueries.find((q) => q.id === queryId);
if (query) {
query.attributes.parameters.forEach((param) => {
defaults[param.name] = param.data_type === "boolean" ? false : "";
});
}
return defaults;
};
const getQueryById = (queryId: string | null) =>
availableQueries.find((query) => query.id === queryId);
const selectedQueryData = getQueryById(selectedQuery);
const form = useForm({
resolver: zodResolver(getValidationSchema(selectedQuery)),
resolver: zodResolver(getValidationSchema(selectedQueryData)),
mode: "onChange",
defaultValues: getDefaultValues(selectedQuery),
defaultValues: getDefaultValues(selectedQueryData),
shouldUnregister: true,
});
// Update form when selectedQuery changes
useEffect(() => {
form.reset(getDefaultValues(selectedQuery), {
form.reset(getDefaultValues(selectedQueryData), {
keepDirtyValues: false,
});
}, [selectedQuery]); // eslint-disable-line react-hooks/exhaustive-deps
const selectedQueryData = availableQueries.find(
(q) => q.id === selectedQuery,
);
}, [form, selectedQueryData]);
const handleQueryChange = (queryId: string) => {
setSelectedQuery(queryId);
form.reset();
};
const getQueryParameters = () => {
return form.getValues();
if (!selectedQueryData?.attributes.parameters.length) {
return undefined;
}
const values = form.getValues() as Record<
string,
string | number | boolean
>;
return selectedQueryData.attributes.parameters.reduce<
Record<string, string | number | boolean>
>((parameters, parameter) => {
const value = values[parameter.name];
if (value !== undefined) {
parameters[parameter.name] = value;
}
return parameters;
}, {});
};
const isFormValid = () => {