mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-22 03:08:23 +00:00
fix(ui): query parameters on Attack Paths stuck between queries (#10306)
This commit is contained in:
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 "Execute Query" 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user