feat(ui): add search bar when adding a provider (#9634)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pedro Martín
2025-12-23 12:09:55 +01:00
committed by GitHub
parent ad5095595c
commit 8ce56b5ed6
5 changed files with 316 additions and 77 deletions

View File

@@ -2,7 +2,15 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.16.1] (Prowler v5.17.1)
## [1.17.0] (Prowler v5.17.0 Unreleased)
### 🚀 Added
- Add search bar when adding a provider [(#9634)](https://github.com/prowler-cloud/prowler/pull/9634)
---
## [1.16.1] (Prowler v5.16.1)
### 🔄 Changed

View File

@@ -1,10 +1,11 @@
"use client";
import { RadioGroup } from "@heroui/radio";
import { FC } from "react";
import { FC, useState } from "react";
import { Control, Controller } from "react-hook-form";
import { z } from "zod";
import { SearchInput } from "@/components/shadcn";
import { cn } from "@/lib/utils";
import { addProviderFormSchema } from "@/types";
import {
@@ -19,9 +20,61 @@ import {
MongoDBAtlasProviderBadge,
OracleCloudProviderBadge,
} from "../icons/providers-badge";
import { CustomRadio } from "../ui/custom";
import { FormMessage } from "../ui/form";
const PROVIDERS = [
{
value: "aws",
label: "Amazon Web Services",
badge: AWSProviderBadge,
},
{
value: "gcp",
label: "Google Cloud Platform",
badge: GCPProviderBadge,
},
{
value: "azure",
label: "Microsoft Azure",
badge: AzureProviderBadge,
},
{
value: "m365",
label: "Microsoft 365",
badge: M365ProviderBadge,
},
{
value: "mongodbatlas",
label: "MongoDB Atlas",
badge: MongoDBAtlasProviderBadge,
},
{
value: "kubernetes",
label: "Kubernetes",
badge: KS8ProviderBadge,
},
{
value: "github",
label: "GitHub",
badge: GitHubProviderBadge,
},
{
value: "iac",
label: "Infrastructure as Code",
badge: IacProviderBadge,
},
{
value: "oraclecloud",
label: "Oracle Cloud Infrastructure",
badge: OracleCloudProviderBadge,
},
{
value: "alibabacloud",
label: "Alibaba Cloud",
badge: AlibabaCloudProviderBadge,
},
] as const;
interface RadioGroupProviderProps {
control: Control<z.infer<typeof addProviderFormSchema>>;
isInvalid: boolean;
@@ -33,90 +86,90 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
isInvalid,
errorMessage,
}) => {
const [searchTerm, setSearchTerm] = useState("");
const lowerSearch = searchTerm.trim().toLowerCase();
const filteredProviders = lowerSearch
? PROVIDERS.filter(
(provider) =>
provider.label.toLowerCase().includes(lowerSearch) ||
provider.value.toLowerCase().includes(lowerSearch),
)
: PROVIDERS;
return (
<Controller
name="providerType"
control={control}
render={({ field }) => (
<>
<RadioGroup
className="flex flex-wrap"
isInvalid={isInvalid}
{...field}
value={field.value || ""}
>
<div className="flex flex-col gap-4">
<CustomRadio description="Amazon Web Services" value="aws">
<div className="flex items-center">
<AWSProviderBadge size={26} />
<span className="ml-2">Amazon Web Services</span>
</div>
</CustomRadio>
<CustomRadio description="Google Cloud Platform" value="gcp">
<div className="flex items-center">
<GCPProviderBadge size={26} />
<span className="ml-2">Google Cloud Platform</span>
</div>
</CustomRadio>
<CustomRadio description="Microsoft Azure" value="azure">
<div className="flex items-center">
<AzureProviderBadge size={26} />
<span className="ml-2">Microsoft Azure</span>
</div>
</CustomRadio>
<CustomRadio description="Microsoft 365" value="m365">
<div className="flex items-center">
<M365ProviderBadge size={26} />
<span className="ml-2">Microsoft 365</span>
</div>
</CustomRadio>
<CustomRadio description="MongoDB Atlas" value="mongodbatlas">
<div className="flex items-center">
<MongoDBAtlasProviderBadge size={26} />
<span className="ml-2">MongoDB Atlas</span>
</div>
</CustomRadio>
<CustomRadio description="Kubernetes" value="kubernetes">
<div className="flex items-center">
<KS8ProviderBadge size={26} />
<span className="ml-2">Kubernetes</span>
</div>
</CustomRadio>
<CustomRadio description="GitHub" value="github">
<div className="flex items-center">
<GitHubProviderBadge size={26} />
<span className="ml-2">GitHub</span>
</div>
</CustomRadio>
<CustomRadio description="Infrastructure as Code" value="iac">
<div className="flex items-center">
<IacProviderBadge size={26} />
<span className="ml-2">Infrastructure as Code</span>
</div>
</CustomRadio>
<CustomRadio
description="Oracle Cloud Infrastructure"
value="oraclecloud"
>
<div className="flex items-center">
<OracleCloudProviderBadge size={26} />
<span className="ml-2">Oracle Cloud Infrastructure</span>
</div>
</CustomRadio>
<CustomRadio description="Alibaba Cloud" value="alibabacloud">
<div className="flex items-center">
<AlibabaCloudProviderBadge size={26} />
<span className="ml-2">Alibaba Cloud</span>
</div>
</CustomRadio>
<div className="flex h-[calc(100vh-200px)] flex-col px-4">
<div className="relative z-10 shrink-0 pb-4">
<SearchInput
aria-label="Search providers"
placeholder="Search providers..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onClear={() => setSearchTerm("")}
/>
</div>
<div className="minimal-scrollbar relative flex-1 overflow-y-auto pr-3">
<div
role="listbox"
aria-label="Select a provider"
className="flex flex-col gap-3"
style={{
maskImage:
"linear-gradient(to bottom, transparent, black 24px)",
WebkitMaskImage:
"linear-gradient(to bottom, transparent, black 24px)",
paddingTop: "24px",
marginTop: "-24px",
}}
>
{filteredProviders.length > 0 ? (
filteredProviders.map((provider) => {
const BadgeComponent = provider.badge;
const isSelected = field.value === provider.value;
return (
<button
key={provider.value}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => field.onChange(provider.value)}
className={cn(
"flex w-full cursor-pointer items-center gap-3 rounded-lg border p-4 text-left transition-all",
"hover:border-button-primary",
"focus-visible:border-button-primary focus-visible:ring-button-primary focus:outline-none focus-visible:ring-1",
isSelected
? "border-button-primary bg-bg-neutral-tertiary"
: "border-border-neutral-secondary bg-bg-neutral-secondary",
isInvalid && "border-bg-fail",
)}
>
<BadgeComponent size={26} />
<span className="text-text-neutral-primary text-sm font-medium">
{provider.label}
</span>
</button>
);
})
) : (
<p className="text-text-neutral-tertiary py-4 text-sm">
No providers found matching &quot;{searchTerm}&quot;
</p>
)}
</div>
</RadioGroup>
</div>
{errorMessage && (
<FormMessage className="text-text-error">
{errorMessage}
</FormMessage>
)}
</>
</div>
)}
/>
);

View File

@@ -8,6 +8,8 @@ export * from "./card/resource-stats-card/resource-stats-card-header";
export * from "./checkbox/checkbox";
export * from "./combobox";
export * from "./dropdown/dropdown";
export * from "./input/input";
export * from "./search-input/search-input";
export * from "./select/multiselect";
export * from "./select/select";
export * from "./separator/separator";

View File

@@ -0,0 +1,51 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { ComponentProps, forwardRef } from "react";
import { cn } from "@/lib/utils";
const inputVariants = cva(
"flex w-full rounded-lg border text-sm transition-all outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50",
{
variants: {
variant: {
default:
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 hover:bg-bg-neutral-secondary dark:hover:bg-input/50 focus:border-border-input-primary-press focus:ring-1 focus:ring-border-input-primary-press focus:ring-offset-1 placeholder:text-text-neutral-tertiary",
ghost:
"border-transparent bg-transparent hover:bg-bg-neutral-tertiary focus:bg-bg-neutral-tertiary placeholder:text-text-neutral-tertiary",
},
inputSize: {
default: "h-10 px-4 py-3",
sm: "h-8 px-3 py-2 text-xs",
lg: "h-12 px-5 py-4",
},
},
defaultVariants: {
variant: "default",
inputSize: "default",
},
},
);
export interface InputProps
extends Omit<ComponentProps<"input">, "size">,
VariantProps<typeof inputVariants> {}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, variant, inputSize, type = "text", ...props }, ref) => {
return (
<input
ref={ref}
type={type}
data-slot="input"
className={cn(inputVariants({ variant, inputSize, className }))}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input, inputVariants };

View File

@@ -0,0 +1,125 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { SearchIcon, XCircle } from "lucide-react";
import { ComponentProps, forwardRef } from "react";
import { cn } from "@/lib/utils";
const searchInputWrapperVariants = cva("relative flex items-center w-full", {
variants: {
size: {
default: "",
sm: "",
lg: "",
},
},
defaultVariants: {
size: "default",
},
});
const searchInputVariants = cva(
"flex w-full rounded-lg border text-sm transition-all outline-none placeholder:text-text-neutral-tertiary disabled:cursor-not-allowed disabled:opacity-50",
{
variants: {
variant: {
default:
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 hover:bg-bg-neutral-secondary dark:hover:bg-input/50 focus:border-border-input-primary-press focus:ring-1 focus:ring-border-input-primary-press focus:ring-offset-1",
ghost:
"border-transparent bg-transparent hover:bg-bg-neutral-tertiary focus:bg-bg-neutral-tertiary",
},
size: {
default: "h-10 pl-10 pr-10 py-3",
sm: "h-8 pl-8 pr-8 py-2 text-xs",
lg: "h-12 pl-12 pr-12 py-4",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const iconSizeMap = {
default: 16,
sm: 14,
lg: 20,
} as const;
const iconPositionMap = {
default: "left-3",
sm: "left-2.5",
lg: "left-4",
} as const;
const clearButtonPositionMap = {
default: "right-3",
sm: "right-2.5",
lg: "right-4",
} as const;
export interface SearchInputProps
extends Omit<ComponentProps<"input">, "size">,
VariantProps<typeof searchInputVariants> {
onClear?: () => void;
}
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
(
{
className,
variant,
size = "default",
value,
onClear,
placeholder = "Search...",
...props
},
ref,
) => {
const iconSize = iconSizeMap[size || "default"];
const iconPosition = iconPositionMap[size || "default"];
const clearButtonPosition = clearButtonPositionMap[size || "default"];
const hasValue = value && String(value).length > 0;
return (
<div className={cn(searchInputWrapperVariants({ size }))}>
<SearchIcon
size={iconSize}
className={cn(
"text-text-neutral-tertiary pointer-events-none absolute",
iconPosition,
)}
/>
<input
ref={ref}
type="text"
data-slot="search-input"
value={value}
placeholder={placeholder}
className={cn(searchInputVariants({ variant, size, className }))}
{...props}
/>
{hasValue && onClear && (
<button
type="button"
aria-label="Clear search"
onClick={onClear}
className={cn(
"text-text-neutral-tertiary hover:text-text-neutral-primary absolute transition-colors focus:outline-none",
clearButtonPosition,
)}
>
<XCircle size={iconSize} />
</button>
)}
</div>
);
},
);
SearchInput.displayName = "SearchInput";
export { SearchInput, searchInputVariants };