fix(ui): replace HeroUI dropdowns with shadcn selects (#10111)

Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
This commit is contained in:
Prowler Bot
2026-02-18 13:55:15 +01:00
committed by GitHub
parent b6c7e24856
commit 987fad3aaf
34 changed files with 846 additions and 998 deletions

View File

@@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🐞 Fixed
- Dropdown selects in the "Send to Jira" modal and other dialogs not responding to clicks [(#10097)](https://github.com/prowler-cloud/prowler/pull/10097)
- Update credentials for the Alibaba Cloud provider [(#10098)](https://github.com/prowler-cloud/prowler/pull/10098)
---

View File

@@ -1,35 +0,0 @@
"use client";
import { Select, SelectItem } from "@heroui/select";
const accounts = [
{ key: "audit-test-1", label: "740350143844" },
{ key: "audit-test-2", label: "890837126756" },
{ key: "audit-test-3", label: "563829104923" },
{ key: "audit-test-4", label: "678943217543" },
{ key: "audit-test-5", label: "932187465320" },
{ key: "audit-test-6", label: "492837106587" },
{ key: "audit-test-7", label: "812736459201" },
{ key: "audit-test-8", label: "374829106524" },
{ key: "audit-test-9", label: "926481053298" },
{ key: "audit-test-10", label: "748192364579" },
{ key: "audit-test-11", label: "501374829106" },
];
export const CustomAccountSelection = () => {
return (
<Select
label="Account"
aria-label="Select an Account"
placeholder="Select an account"
classNames={{
selectorIcon: "right-2",
}}
selectionMode="multiple"
className="w-full"
size="sm"
>
{accounts.map((acc) => (
<SelectItem key={acc.key}>{acc.label}</SelectItem>
))}
</Select>
);
};

View File

@@ -1,48 +0,0 @@
"use client";
import { Select, SelectItem } from "@heroui/select";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useCallback, useMemo } from "react";
export const CustomRegionSelection: React.FC = () => {
const router = useRouter();
const searchParams = useSearchParams();
const region = "none";
// Memoize selected keys based on the URL
const selectedKeys = useMemo(() => {
const params = searchParams.get("filter[regions]");
return params ? params.split(",") : [];
}, [searchParams]);
const applyRegionFilter = useCallback(
(values: string[]) => {
const params = new URLSearchParams(searchParams.toString());
if (values.length > 0) {
params.set("filter[regions]", values.join(","));
} else {
params.delete("filter[regions]");
}
router.push(`?${params.toString()}`, { scroll: false });
},
[router, searchParams],
);
return (
<Select
label="Region"
aria-label="Select a Region"
placeholder="Select a region"
classNames={{
selectorIcon: "right-2",
}}
className="w-full"
size="sm"
selectedKeys={selectedKeys}
onSelectionChange={(keys) =>
applyRegionFilter(Array.from(keys) as string[])
}
>
<SelectItem key={region}>{region}</SelectItem>
</Select>
);
};

View File

@@ -1,128 +0,0 @@
"use client";
import { Select, SelectItem } from "@heroui/select";
import { useRouter, useSearchParams } from "next/navigation";
import { ReactElement } from "react";
import { PROVIDER_TYPES, ProviderType } from "@/types/providers";
import {
CustomProviderInputAlibabaCloud,
CustomProviderInputAWS,
CustomProviderInputAzure,
CustomProviderInputGCP,
CustomProviderInputGitHub,
CustomProviderInputIac,
CustomProviderInputKubernetes,
CustomProviderInputM365,
CustomProviderInputMongoDBAtlas,
CustomProviderInputOracleCloud,
} from "./custom-provider-inputs";
const providerDisplayData: Record<
ProviderType,
{ label: string; component: ReactElement }
> = {
aws: {
label: "Amazon Web Services",
component: <CustomProviderInputAWS />,
},
azure: {
label: "Microsoft Azure",
component: <CustomProviderInputAzure />,
},
gcp: {
label: "Google Cloud Platform",
component: <CustomProviderInputGCP />,
},
github: {
label: "GitHub",
component: <CustomProviderInputGitHub />,
},
iac: {
label: "Infrastructure as Code",
component: <CustomProviderInputIac />,
},
kubernetes: {
label: "Kubernetes",
component: <CustomProviderInputKubernetes />,
},
m365: {
label: "Microsoft 365",
component: <CustomProviderInputM365 />,
},
mongodbatlas: {
label: "MongoDB Atlas",
component: <CustomProviderInputMongoDBAtlas />,
},
oraclecloud: {
label: "Oracle Cloud Infrastructure",
component: <CustomProviderInputOracleCloud />,
},
alibabacloud: {
label: "Alibaba Cloud",
component: <CustomProviderInputAlibabaCloud />,
},
};
const dataInputsProvider = PROVIDER_TYPES.map((providerType) => ({
key: providerType,
label: providerDisplayData[providerType].label,
value: providerDisplayData[providerType].component,
}));
export const CustomSelectProvider = () => {
const router = useRouter();
const searchParams = useSearchParams();
const applyProviderFilter = (value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set("filter[provider_type]", value);
} else {
params.delete("filter[provider_type]");
}
router.push(`?${params.toString()}`, { scroll: false });
};
const currentProvider = searchParams.get("filter[provider_type]") || "";
const selectedKeys = dataInputsProvider.some(
(provider) => provider.key === currentProvider,
)
? [currentProvider]
: [];
return (
<Select
items={dataInputsProvider}
aria-label="Select a Provider"
placeholder="Select a provider"
classNames={{
selectorIcon: "right-2",
label: "z-0! mb-2",
}}
label="Provider"
labelPlacement="inside"
size="sm"
onChange={(e) => {
const value = e.target.value;
applyProviderFilter(value);
}}
selectedKeys={selectedKeys}
renderValue={(items) => {
return items.map((item) => (
<div key={item.key} className="flex items-center gap-2">
{item.data?.value}
</div>
));
}}
>
{(item) => (
<SelectItem key={item.key} textValue={item.key} aria-label={item.label}>
<div className="flex items-center gap-2">{item.value}</div>
</SelectItem>
)}
</Select>
);
};

View File

@@ -3,30 +3,15 @@
import { FilterOption } from "@/types";
import { DataTableFilterCustom } from "../ui/table";
import { CustomAccountSelection } from "./custom-account-selection";
import { CustomCheckboxMutedFindings } from "./custom-checkbox-muted-findings";
import { CustomDatePicker } from "./custom-date-picker";
import { CustomRegionSelection } from "./custom-region-selection";
import { CustomSearchInput } from "./custom-search-input";
import { CustomSelectProvider } from "./custom-select-provider";
export interface FilterControlsProps {
search?: boolean;
providers?: boolean;
date?: boolean;
regions?: boolean;
accounts?: boolean;
mutedFindings?: boolean;
customFilters?: FilterOption[];
}
export const FilterControls = ({
search = false,
providers = false,
date = false,
regions = false,
accounts = false,
mutedFindings = false,
customFilters,
}: FilterControlsProps) => {
return (
@@ -34,11 +19,6 @@ export const FilterControls = ({
<div className="mb-4 flex flex-col items-start gap-4 md:flex-row md:items-center">
<div className="grid w-full flex-1 grid-cols-1 items-center gap-x-4 gap-y-4 md:grid-cols-2 xl:grid-cols-4">
{search && <CustomSearchInput />}
{providers && <CustomSelectProvider />}
{date && <CustomDatePicker />}
{regions && <CustomRegionSelection />}
{accounts && <CustomAccountSelection />}
{mutedFindings && <CustomCheckboxMutedFindings />}
</div>
</div>
{customFilters && customFilters.length > 0 && (

View File

@@ -1,9 +1,6 @@
export * from "./clear-filters-button";
export * from "./custom-account-selection";
export * from "./custom-checkbox-muted-findings";
export * from "./custom-date-picker";
export * from "./custom-provider-inputs";
export * from "./custom-region-selection";
export * from "./custom-select-provider";
export * from "./data-filters";
export * from "./filter-controls";

View File

@@ -1,10 +1,7 @@
"use client";
import { Input } from "@heroui/input";
import { Select, SelectItem } from "@heroui/select";
import { zodResolver } from "@hookform/resolvers/zod";
import type { Selection } from "@react-types/shared";
import { Search, Send } from "lucide-react";
import { Send } from "lucide-react";
import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -14,16 +11,11 @@ import {
pollJiraDispatchTask,
sendFindingToJira,
} from "@/actions/integrations/jira-dispatch";
import { JiraIcon } from "@/components/icons/services/IconServices";
import { Modal } from "@/components/shadcn/modal";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
import { CustomBanner } from "@/components/ui/custom/custom-banner";
import {
Form,
FormControl,
FormField,
FormMessage,
} from "@/components/ui/form";
import { Form, FormField, FormMessage } from "@/components/ui/form";
import { FormButtons } from "@/components/ui/form/form-buttons";
import { IntegrationProps } from "@/types/integrations";
@@ -42,15 +34,6 @@ const sendToJiraSchema = z.object({
type SendToJiraFormData = z.infer<typeof sendToJiraSchema>;
const selectorClassNames = {
trigger: "min-h-12",
popoverContent: "bg-bg-neutral-secondary",
listboxWrapper: "max-h-[300px] bg-bg-neutral-secondary",
listbox: "gap-0",
label: "tracking-tight font-light !text-text-neutral-secondary text-xs z-0!",
value: "text-text-neutral-secondary text-small",
};
// The commented code is related to issue types, which are not required for the first implementation, but will be used in the future
export const SendToJiraModal = ({
isOpen,
@@ -61,8 +44,6 @@ export const SendToJiraModal = ({
const { toast } = useToast();
const [integrations, setIntegrations] = useState<IntegrationProps[]>([]);
const [isFetchingIntegrations, setIsFetchingIntegrations] = useState(false);
const [searchProjectValue, setSearchProjectValue] = useState("");
// const [searchIssueTypeValue, setSearchIssueTypeValue] = useState("");
const form = useForm<SendToJiraFormData>({
resolver: zodResolver(sendToJiraSchema),
@@ -75,18 +56,11 @@ export const SendToJiraModal = ({
});
const selectedIntegration = form.watch("integration");
// const selectedProject = form.watch("project");
const hasConnectedIntegration = integrations.some(
(i) => i.attributes.connected === true,
);
const getSelectedValue = (keys: Selection): string => {
if (keys === "all") return "";
const first = Array.from(keys)[0];
return first !== null ? String(first) : "";
};
const setOpenForFormButtons: Dispatch<SetStateAction<boolean>> = (value) => {
const next = typeof value === "function" ? value(isOpen) : value;
onOpenChange(next);
@@ -129,8 +103,6 @@ export const SendToJiraModal = ({
} else {
// Reset form when modal closes
form.reset();
setSearchProjectValue("");
// setSearchIssueTypeValue("");
}
}, [isOpen, form, toast]);
@@ -187,32 +159,16 @@ export const SendToJiraModal = ({
({} as Record<string, string>);
const projectEntries = Object.entries(projects);
const shouldShowProjectSearch = projectEntries.length > 5;
// const issueTypes: string[] =
// selectedIntegrationData?.attributes.configuration.issue_types ||
// ([] as string[]);
// Filter projects based on search
const filteredProjects = (() => {
if (!searchProjectValue) return projectEntries;
const integrationOptions = integrations.map((integration) => ({
value: integration.id,
label: integration.attributes.configuration.domain || integration.id,
}));
const lowerSearch = searchProjectValue.toLowerCase();
return projectEntries.filter(
([key, name]) =>
key.toLowerCase().includes(lowerSearch) ||
name.toLowerCase().includes(lowerSearch),
);
})();
// Filter issue types based on search
// const filteredIssueTypes = useMemo(() => {
// if (!searchIssueTypeValue) return issueTypes;
// const lowerSearch = searchIssueTypeValue.toLowerCase();
// return issueTypes.filter((type) =>
// type.toLowerCase().includes(lowerSearch),
// );
// }, [issueTypes, searchIssueTypeValue]);
const projectOptions = projectEntries.map(([key, name]) => ({
value: key,
label: `${key} - ${name}`,
}));
return (
<Modal
@@ -236,127 +192,72 @@ export const SendToJiraModal = ({
control={form.control}
name="integration"
render={({ field }) => (
<>
<FormControl>
<Select
label="Jira Integration"
placeholder="Select a Jira integration"
selectedKeys={
field.value ? new Set([field.value]) : new Set()
}
onSelectionChange={(keys: Selection) => {
const value = getSelectedValue(keys);
field.onChange(value);
// Reset dependent fields
form.setValue("project", "");
// Keep issue type defaulting to Task
form.setValue("issueType", "Task");
setSearchProjectValue("");
// setSearchIssueTypeValue("");
}}
variant="bordered"
labelPlacement="inside"
isDisabled={isFetchingIntegrations}
isInvalid={!!form.formState.errors.integration}
startContent={<JiraIcon size={16} />}
classNames={selectorClassNames}
>
{integrations.map((integration) => (
<SelectItem
key={integration.id}
textValue={
integration.attributes.configuration.domain
}
>
<div className="flex items-center gap-2">
<JiraIcon size={16} />
<span>
{integration.attributes.configuration.domain}
</span>
</div>
</SelectItem>
))}
</Select>
</FormControl>
<div className="flex flex-col gap-1.5">
<label
htmlFor="jira-integration-select"
className="text-text-neutral-secondary text-xs font-light tracking-tight"
>
Jira Integration
</label>
<EnhancedMultiSelect
id="jira-integration-select"
options={integrationOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
// Reset dependent fields
form.setValue("project", "");
form.setValue("issueType", "Task");
}}
defaultValue={field.value ? [field.value] : []}
placeholder="Select a Jira integration"
searchable={true}
emptyIndicator="No integrations found."
disabled={isFetchingIntegrations}
hideSelectAll={true}
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
/>
<FormMessage className="text-text-error text-xs" />
</>
</div>
)}
/>
)}
{/* Project Selection - Enhanced Style */}
{selectedIntegration && Object.keys(projects).length > 0 && (
{/* Project Selection */}
{selectedIntegration && projectEntries.length > 0 && (
<FormField
control={form.control}
name="project"
render={({ field }) => (
<>
<FormControl>
<Select
label="Project"
placeholder="Select a Jira project"
selectedKeys={
field.value ? new Set([field.value]) : new Set()
}
onSelectionChange={(keys: Selection) => {
const value = getSelectedValue(keys);
field.onChange(value);
// Keep issue type defaulting to Task when project changes
form.setValue("issueType", "Task");
// setSearchIssueTypeValue("");
}}
variant="bordered"
labelPlacement="inside"
isInvalid={!!form.formState.errors.project}
classNames={selectorClassNames}
listboxProps={{
topContent: shouldShowProjectSearch ? (
<div className="sticky top-0 z-10 py-2">
<Input
isClearable
placeholder="Search projects..."
size="sm"
variant="bordered"
startContent={<Search size={16} />}
value={searchProjectValue}
onValueChange={setSearchProjectValue}
onClear={() => setSearchProjectValue("")}
classNames={{
inputWrapper:
"border-border-input-primary bg-bg-input-primary hover:bg-bg-neutral-secondary",
input: "text-small",
clearButton: "text-default-400",
}}
/>
</div>
) : null,
}}
>
{filteredProjects.map(([key, name]) => (
<SelectItem key={key} textValue={`${key} - ${name}`}>
<div className="flex w-full items-center justify-between py-1">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-small font-semibold">
{key}
</span>
<span className="text-tiny text-default-500">
-
</span>
<span className="text-small truncate">
{name}
</span>
</div>
</div>
</div>
</div>
</SelectItem>
))}
</Select>
</FormControl>
<div className="flex flex-col gap-1.5">
<label
htmlFor="jira-project-select"
className="text-text-neutral-secondary text-xs font-light tracking-tight"
>
Project
</label>
<EnhancedMultiSelect
id="jira-project-select"
options={projectOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
// Keep issue type defaulting to Task when project changes
form.setValue("issueType", "Task");
}}
defaultValue={field.value ? [field.value] : []}
placeholder="Select a Jira project"
searchable={true}
emptyIndicator="No projects found."
hideSelectAll={true}
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
/>
<FormMessage className="text-text-error text-xs" />
</>
</div>
)}
/>
)}

View File

@@ -12,7 +12,7 @@ import {
} from "@/components/icons/providers-badge";
import { ProviderType } from "@/types";
const PROVIDER_ICONS = {
export const PROVIDER_ICONS = {
aws: AWSProviderBadge,
azure: AzureProviderBadge,
gcp: GCPProviderBadge,

View File

@@ -1,4 +1,3 @@
export * from "../providers/enhanced-provider-selector";
export * from "./api-key/api-key-link-card";
export * from "./jira/jira-integration-card";
export * from "./jira/jira-integration-form";

View File

@@ -8,12 +8,18 @@ import { useState } from "react";
import { Control, useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
import { EnhancedProviderSelector } from "@/components/providers/enhanced-provider-selector";
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { Form } from "@/components/ui/form";
import {
Form,
FormControl,
FormField,
FormMessage,
} from "@/components/ui/form";
import { FormButtons } from "@/components/ui/form/form-buttons";
import { getAWSCredentialsTemplateLinks } from "@/lib";
import { AWSCredentialsRole } from "@/types";
@@ -272,18 +278,40 @@ export const S3IntegrationForm = ({
// Show configuration step (step 0 or editing configuration)
if (isEditingConfig || currentStep === 0) {
const providerOptions = providers.map((provider) => {
const Icon = PROVIDER_ICONS[provider.attributes.provider];
return {
value: provider.id,
label: provider.attributes.alias || provider.attributes.uid,
icon: Icon ? <Icon width={20} height={20} /> : undefined,
description: provider.attributes.connection.connected
? "Connected"
: "Disconnected",
};
});
return (
<>
{/* Provider Selection */}
<div className="flex flex-col gap-4">
<EnhancedProviderSelector
<FormField
control={form.control}
name="providers"
providers={providers}
label="Cloud Providers"
placeholder="Select providers to integrate with"
selectionMode="multiple"
enableSearch={true}
render={({ field }) => (
<>
<FormControl>
<EnhancedMultiSelect
options={providerOptions}
onValueChange={field.onChange}
defaultValue={field.value || []}
placeholder="Select providers to integrate with"
searchable={true}
maxCount={1}
/>
</FormControl>
<FormMessage className="text-text-error max-w-full text-xs" />
</>
)}
/>
</div>

View File

@@ -6,15 +6,21 @@ import { Radio, RadioGroup } from "@heroui/radio";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { Control, useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
import { EnhancedProviderSelector } from "@/components/providers/enhanced-provider-selector";
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { Form, FormControl, FormField } from "@/components/ui/form";
import {
Form,
FormControl,
FormField,
FormMessage,
} from "@/components/ui/form";
import { FormButtons } from "@/components/ui/form/form-buttons";
import { getAWSCredentialsTemplateLinks } from "@/lib";
import { AWSCredentialsRole } from "@/types";
@@ -52,7 +58,7 @@ export const SecurityHubIntegrationForm = ({
const isEditingConfig = editMode === "configuration";
const isEditingCredentials = editMode === "credentials";
const disabledProviderIds = useMemo(() => {
const disabledProviderIds = (() => {
// When editing, no providers should be disabled since we're not changing it
if (isEditing) {
return [];
@@ -69,7 +75,7 @@ export const SecurityHubIntegrationForm = ({
});
return usedProviderIds;
}, [isEditing, existingIntegrations]);
})();
const form = useForm({
resolver: zodResolver(
@@ -107,6 +113,26 @@ export const SecurityHubIntegrationForm = ({
const providerIdValue = form.watch("provider_id");
const hasErrors = !!form.formState.errors.provider_id || !providerIdValue;
const providerOptions = providers
.filter((provider) => provider.attributes.provider === "aws")
.map((provider) => {
const isDisabled = disabledProviderIds.includes(provider.id);
const connectionLabel = provider.attributes.connection.connected
? "Connected"
: "Disconnected";
const Icon = PROVIDER_ICONS[provider.attributes.provider];
return {
value: provider.id,
label: provider.attributes.alias || provider.attributes.uid,
icon: Icon ? <Icon width={20} height={20} /> : undefined,
description: isDisabled
? `${connectionLabel} (Already in use)`
: connectionLabel,
disabled: isDisabled,
};
});
useEffect(() => {
if (!useCustomCredentials && isCreating) {
setCurrentStep(0);
@@ -325,17 +351,29 @@ export const SecurityHubIntegrationForm = ({
{!isEditingConfig && (
<>
<div className="flex flex-col gap-4">
<EnhancedProviderSelector
<FormField
control={form.control}
name="provider_id"
providers={providers}
label="AWS Provider"
placeholder="Search and select an AWS provider"
isInvalid={!!form.formState.errors.provider_id}
selectionMode="single"
providerType="aws"
enableSearch={true}
disabledProviderIds={disabledProviderIds}
render={({ field }) => (
<>
<FormControl>
<EnhancedMultiSelect
options={providerOptions}
onValueChange={(values) => {
field.onChange(values.at(-1) ?? "");
}}
defaultValue={field.value ? [field.value] : []}
placeholder="Search and select an AWS provider"
searchable={true}
hideSelectAll={true}
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
/>
</FormControl>
<FormMessage className="text-text-error max-w-full text-xs" />
</>
)}
/>
</div>
<Divider />

View File

@@ -1,6 +1,5 @@
"use client";
import { Chip } from "@heroui/chip";
import { format } from "date-fns";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
@@ -16,7 +15,7 @@ import {
IntegrationCardHeader,
IntegrationSkeleton,
} from "@/components/integrations/shared";
import { Button } from "@/components/shadcn";
import { Badge, Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
@@ -385,14 +384,13 @@ export const SecurityHubIntegrationsManager = ({
{enabledRegions.length > 0 && (
<div className="flex flex-wrap gap-1">
{enabledRegions.map((region) => (
<Chip
<Badge
key={region}
size="sm"
variant="flat"
className="bg-bg-neutral-secondary"
variant="outline"
className="border-border-neutral-secondary bg-bg-neutral-secondary text-text-neutral-primary text-xs font-normal"
>
{region}
</Chip>
</Badge>
))}
</div>
)}

View File

@@ -1,23 +1,18 @@
"use client";
import { Chip } from "@heroui/chip";
import { ExternalLink } from "lucide-react";
import { ReactNode } from "react";
import { Badge } from "@/components/shadcn";
import { cn } from "@/lib/utils";
interface IntegrationCardHeaderProps {
icon: ReactNode;
title: string;
subtitle?: string;
chips?: Array<{
label: string;
color?:
| "default"
| "primary"
| "secondary"
| "success"
| "warning"
| "danger";
variant?: "solid" | "bordered" | "light" | "flat" | "faded" | "shadow";
className?: string;
}>;
connectionStatus?: {
connected: boolean;
@@ -63,25 +58,30 @@ export const IntegrationCardHeader = ({
{(chips.length > 0 || connectionStatus) && (
<div className="flex flex-wrap items-center gap-2">
{chips.map((chip, index) => (
<Chip
<Badge
key={index}
size="sm"
variant={chip.variant || "flat"}
color={chip.color || "default"}
className="text-xs"
variant="outline"
className={cn(
"border-border-neutral-secondary bg-bg-neutral-secondary text-text-neutral-primary text-xs font-normal",
chip.className,
)}
>
{chip.label}
</Chip>
</Badge>
))}
{connectionStatus && (
<Chip
size="sm"
color={connectionStatus.connected ? "success" : "danger"}
variant="flat"
<Badge
variant="outline"
className={cn(
"text-xs font-normal",
connectionStatus.connected
? "bg-bg-pass-secondary text-text-success-primary border-transparent"
: "bg-bg-danger-secondary text-text-danger border-transparent",
)}
>
{connectionStatus.label ||
(connectionStatus.connected ? "Connected" : "Disconnected")}
</Chip>
</Badge>
)}
</div>
)}

View File

@@ -1,4 +1,3 @@
import { Select, SelectItem } from "@heroui/select";
import { zodResolver } from "@hookform/resolvers/zod";
import { MailIcon, ShieldIcon } from "lucide-react";
import { Dispatch, SetStateAction } from "react";
@@ -6,6 +5,13 @@ import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
import { updateInvite } from "@/actions/invitations/invitation";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { Form, FormButtons } from "@/components/ui/form";
@@ -126,27 +132,25 @@ export const EditForm = ({
isRequired={false}
/>
</div>
<div>
<div className="flex flex-col gap-1.5">
<label className="text-text-neutral-secondary text-sm font-medium">
Role
</label>
<Controller
name="role"
control={form.control}
render={({ field }) => (
<Select
{...field}
label="Role"
placeholder="Select a role"
classNames={{
selectorIcon: "right-2",
}}
variant="flat"
selectedKeys={[field.value || ""]}
onSelectionChange={(selected) =>
field.onChange(selected?.currentKey || "")
}
>
{roles.map((role) => (
<SelectItem key={role.id}>{role.name}</SelectItem>
))}
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>

View File

@@ -1,6 +1,5 @@
"use client";
import { Select, SelectItem } from "@heroui/select";
import { zodResolver } from "@hookform/resolvers/zod";
import { SaveIcon } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -9,6 +8,13 @@ import * as z from "zod";
import { sendInvite } from "@/actions/invitations/invitation";
import { Button } from "@/components/shadcn";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
@@ -111,35 +117,33 @@ export const SendInvitationForm = ({
name="roleId"
control={form.control}
render={({ field }) => (
<>
<div className="flex flex-col gap-1.5">
<Select
{...field}
label="Role"
placeholder="Select a role"
classNames={{
selectorIcon: "right-2",
}}
variant="flat"
isDisabled={isSelectorDisabled}
selectedKeys={[field.value]}
onSelectionChange={(selected) =>
field.onChange(selected?.currentKey || "")
}
value={field.value || undefined}
onValueChange={field.onChange}
disabled={isSelectorDisabled}
>
{isSelectorDisabled ? (
<SelectItem key={defaultRole}>{defaultRole}</SelectItem>
) : (
roles.map((role) => (
<SelectItem key={role.id}>{role.name}</SelectItem>
))
)}
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{isSelectorDisabled ? (
<SelectItem value={defaultRole}>{defaultRole}</SelectItem>
) : (
roles.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
{form.formState.errors.roleId && (
<p className="text-text-error mt-2 text-sm">
{form.formState.errors.roleId.message}
</p>
)}
</>
</div>
)}
/>

View File

@@ -7,8 +7,9 @@ import * as z from "zod";
import { createProviderGroup } from "@/actions/manage-groups";
import { Button } from "@/components/shadcn";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
import { CustomDropdownSelection, CustomInput } from "@/components/ui/custom";
import { CustomInput } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
import { ApiError } from "@/types";
@@ -39,6 +40,14 @@ export const AddGroupForm = ({
});
const isLoading = form.formState.isSubmitting;
const providerOptions = providers.map((provider) => ({
label: provider.name,
value: provider.id,
}));
const roleOptions = roles.map((role) => ({
label: role.name,
value: role.id,
}));
const onSubmitClient = async (values: FormValues) => {
try {
@@ -128,15 +137,19 @@ export const AddGroupForm = ({
name="providers"
control={form.control}
render={({ field }) => (
<CustomDropdownSelection
label="Select Providers"
name="providers"
values={providers}
selectedKeys={field.value || []}
onChange={(name, selectedValues) =>
field.onChange(selectedValues)
}
/>
<div className="flex flex-col gap-2">
<EnhancedMultiSelect
options={providerOptions}
onValueChange={field.onChange}
defaultValue={field.value || []}
placeholder="Select providers"
aria-label="Select providers"
searchable={true}
hideSelectAll={true}
emptyIndicator="No results found"
resetOnDefaultValueChange={true}
/>
</div>
)}
/>
{form.formState.errors.providers && (
@@ -155,15 +168,19 @@ export const AddGroupForm = ({
name="roles"
control={form.control}
render={({ field }) => (
<CustomDropdownSelection
label="Select Roles"
name="roles"
values={roles}
selectedKeys={field.value || []}
onChange={(name, selectedValues) =>
field.onChange(selectedValues)
}
/>
<div className="flex flex-col gap-2">
<EnhancedMultiSelect
options={roleOptions}
onValueChange={field.onChange}
defaultValue={field.value || []}
placeholder="Select roles"
aria-label="Select roles"
searchable={true}
hideSelectAll={true}
emptyIndicator="No results found"
resetOnDefaultValueChange={true}
/>
</div>
)}
/>
{form.formState.errors.roles && (

View File

@@ -9,8 +9,9 @@ import * as z from "zod";
import { updateProviderGroup } from "@/actions/manage-groups/manage-groups";
import { Button } from "@/components/shadcn";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
import { CustomDropdownSelection, CustomInput } from "@/components/ui/custom";
import { CustomInput } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
import { ApiError } from "@/types";
@@ -176,18 +177,29 @@ export const EditGroupForm = ({
];
return (
<CustomDropdownSelection
label="Select Providers"
name="providers"
values={combinedProviders}
selectedKeys={field.value?.map((p) => p.id) || []}
onChange={(name, selectedValues) => {
const selectedProviders = combinedProviders.filter(
(provider) => selectedValues.includes(provider.id),
);
field.onChange(selectedProviders);
}}
/>
<div className="flex flex-col gap-2">
<EnhancedMultiSelect
options={combinedProviders.map((provider) => ({
label: provider.name,
value: provider.id,
}))}
onValueChange={(selectedValues) => {
const selectedProviders = combinedProviders.filter(
(provider) => selectedValues.includes(provider.id),
);
field.onChange(selectedProviders);
}}
defaultValue={
field.value?.map((provider) => provider.id) || []
}
placeholder="Select providers"
aria-label="Select providers"
searchable={true}
hideSelectAll={true}
emptyIndicator="No results found"
resetOnDefaultValueChange={true}
/>
</div>
);
}}
/>
@@ -216,18 +228,27 @@ export const EditGroupForm = ({
];
return (
<CustomDropdownSelection
label="Select Roles"
name="roles"
values={combinedRoles}
selectedKeys={field.value?.map((r) => r.id) || []}
onChange={(name, selectedValues) => {
const selectedRoles = combinedRoles.filter((role) =>
selectedValues.includes(role.id),
);
field.onChange(selectedRoles);
}}
/>
<div className="flex flex-col gap-2">
<EnhancedMultiSelect
options={combinedRoles.map((role) => ({
label: role.name,
value: role.id,
}))}
onValueChange={(selectedValues) => {
const selectedRoles = combinedRoles.filter((role) =>
selectedValues.includes(role.id),
);
field.onChange(selectedRoles);
}}
defaultValue={field.value?.map((role) => role.id) || []}
placeholder="Select roles"
aria-label="Select roles"
searchable={true}
hideSelectAll={true}
emptyIndicator="No results found"
resetOnDefaultValueChange={true}
/>
</div>
);
}}
/>

View File

@@ -1,307 +0,0 @@
"use client";
import { Input } from "@heroui/input";
import { Select, SelectItem } from "@heroui/select";
import { SharedSelection } from "@heroui/system";
import { CheckSquare, Search, Square } from "lucide-react";
import { useState } from "react";
import { Control, FieldValues, Path } from "react-hook-form";
import { Button } from "@/components/shadcn";
import { FormControl, FormField, FormMessage } from "@/components/ui/form";
import { ProviderProps, ProviderType } from "@/types/providers";
const providerTypeLabels: Record<ProviderType, string> = {
aws: "Amazon Web Services",
gcp: "Google Cloud Platform",
azure: "Microsoft Azure",
m365: "Microsoft 365",
kubernetes: "Kubernetes",
github: "GitHub",
iac: "Infrastructure as Code",
oraclecloud: "Oracle Cloud Infrastructure",
mongodbatlas: "MongoDB Atlas",
alibabacloud: "Alibaba Cloud",
};
interface EnhancedProviderSelectorProps<T extends FieldValues> {
control: Control<T>;
name: Path<T>;
providers: ProviderProps[];
label?: string;
placeholder?: string;
isInvalid?: boolean;
showFormMessage?: boolean;
selectionMode?: "single" | "multiple";
providerType?: ProviderType;
enableSearch?: boolean;
disabledProviderIds?: string[];
}
export const EnhancedProviderSelector = <T extends FieldValues>({
control,
name,
providers,
label = "Provider",
placeholder = "Select provider",
isInvalid = false,
showFormMessage = true,
selectionMode = "single",
providerType,
enableSearch = false,
disabledProviderIds = [],
}: EnhancedProviderSelectorProps<T>) => {
const [searchValue, setSearchValue] = useState("");
const filteredProviders = (() => {
let filtered = providers;
// Filter by provider type if specified
if (providerType) {
filtered = filtered.filter((p) => p.attributes.provider === providerType);
}
// Filter by search value
if (searchValue && enableSearch) {
const lowerSearch = searchValue.toLowerCase();
filtered = filtered.filter((p) => {
const displayName = p.attributes.alias || p.attributes.uid;
const typeLabel = providerTypeLabels[p.attributes.provider];
return (
displayName.toLowerCase().includes(lowerSearch) ||
typeLabel.toLowerCase().includes(lowerSearch)
);
});
}
// Sort providers
return filtered.sort((a, b) => {
const typeComparison = a.attributes.provider.localeCompare(
b.attributes.provider,
);
if (typeComparison !== 0) return typeComparison;
const nameA = a.attributes.alias || a.attributes.uid;
const nameB = b.attributes.alias || b.attributes.uid;
return nameA.localeCompare(nameB);
});
})();
return (
<FormField
control={control}
name={name}
render={({ field: { onChange, value, onBlur } }) => {
const isMultiple = selectionMode === "multiple";
const selectedIds: string[] = isMultiple
? (value as string[] | undefined) || []
: value
? [value as string]
: [];
const allProviderIds = filteredProviders
.filter((p) => !disabledProviderIds.includes(p.id))
.map((p) => p.id);
const isAllSelected =
isMultiple &&
allProviderIds.length > 0 &&
allProviderIds.every((id) => selectedIds.includes(id));
const handleSelectAll = () => {
if (isAllSelected) {
onChange([]);
} else {
onChange(allProviderIds);
}
};
const handleSelectionChange = (keys: SharedSelection) => {
if (keys === "all") {
onChange(allProviderIds);
return;
}
if (isMultiple) {
const selectedArray = Array.from(keys).map(String);
onChange(selectedArray);
} else {
const selectedValue = Array.from(keys)[0];
onChange(selectedValue ? String(selectedValue) : "");
}
};
return (
<>
<FormControl>
<div className="flex flex-col gap-2">
{isMultiple && filteredProviders.length > 1 && (
<div className="flex items-center justify-between">
<span className="text-text-neutral-primary text-sm font-medium">
{label}
</span>
<Button
size="sm"
variant="ghost"
onClick={handleSelectAll}
className="h-7 text-xs"
>
{isAllSelected ? (
<CheckSquare size={16} />
) : (
<Square size={16} />
)}
{isAllSelected ? "Deselect All" : "Select All"}
</Button>
</div>
)}
<Select
label={label}
placeholder={placeholder}
selectionMode={isMultiple ? "multiple" : "single"}
selectedKeys={
new Set(isMultiple ? value || [] : value ? [value] : [])
}
onSelectionChange={handleSelectionChange}
onBlur={onBlur}
variant="bordered"
labelPlacement="inside"
isRequired={false}
isInvalid={isInvalid}
classNames={{
trigger: "min-h-12",
popoverContent: "bg-bg-neutral-secondary",
listboxWrapper: "max-h-[300px] bg-bg-neutral-secondary",
listbox: "gap-0",
label:
"tracking-tight font-light !text-text-neutral-secondary text-xs z-0!",
value: "text-text-neutral-secondary text-small",
}}
renderValue={(items) => {
if (!isMultiple && value) {
const provider = providers.find((p) => p.id === value);
if (provider) {
const displayName =
provider.attributes.alias || provider.attributes.uid;
return (
<div className="flex items-center gap-2">
<span className="truncate">{displayName}</span>
</div>
);
}
}
if (items.length === 0) {
return (
<span className="text-default-500">{placeholder}</span>
);
}
if (isMultiple) {
if (items.length === 1) {
const provider = providers.find(
(p) => p.id === items[0].key,
);
if (provider) {
const displayName =
provider.attributes.alias ||
provider.attributes.uid;
return (
<div className="flex items-center gap-2">
<span className="truncate">{displayName}</span>
</div>
);
}
}
return (
<span className="text-small">
{items.length} provider{items.length !== 1 ? "s" : ""}{" "}
selected
</span>
);
}
return null;
}}
listboxProps={{
topContent: enableSearch ? (
<div className="sticky top-0 z-10 py-2">
<Input
isClearable
placeholder="Search providers..."
size="sm"
variant="bordered"
startContent={<Search size={16} />}
value={searchValue}
onValueChange={setSearchValue}
onClear={() => setSearchValue("")}
classNames={{
inputWrapper:
"border-border-input-primary bg-bg-input-primary hover:bg-bg-neutral-secondary",
input: "text-small",
clearButton: "text-default-400",
}}
/>
</div>
) : null,
}}
>
{filteredProviders.map((provider) => {
const providerType = provider.attributes.provider;
const displayName =
provider.attributes.alias || provider.attributes.uid;
const typeLabel = providerTypeLabels[providerType];
const isDisabled = disabledProviderIds.includes(
provider.id,
);
return (
<SelectItem
key={provider.id}
textValue={`${displayName} ${typeLabel}`}
className={`py-2 ${isDisabled ? "pointer-events-none cursor-not-allowed opacity-50" : ""}`}
>
<div className="flex w-full items-center justify-between">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="min-w-0 flex-1">
<div className="text-small truncate font-medium">
{displayName}
</div>
<div className="text-tiny text-text-neutral-secondary truncate">
{typeLabel}
{isDisabled && (
<span className="text-text-error ml-2">
(Already used)
</span>
)}
</div>
</div>
</div>
<div className="ml-2 flex shrink-0 items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${
provider.attributes.connection.connected
? "bg-bg-pass"
: "bg-bg-fail"
}`}
title={
provider.attributes.connection.connected
? "Connected"
: "Disconnected"
}
/>
</div>
</div>
</SelectItem>
);
})}
</Select>
</div>
</FormControl>
{showFormMessage && (
<FormMessage className="text-text-error max-w-full text-xs" />
)}
</>
);
}}
/>
);
};

View File

@@ -1,11 +1,17 @@
import { Chip } from "@heroui/chip";
import { Divider } from "@heroui/divider";
import { Select, SelectItem } from "@heroui/select";
import { Switch } from "@heroui/switch";
import { useEffect, useState } from "react";
import { Control, UseFormSetValue, useWatch } from "react-hook-form";
import { CredentialsRoleHelper } from "@/components/providers/workflow";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { CustomInput } from "@/components/ui/custom";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { AWSCredentialsRole } from "@/types";
@@ -77,47 +83,47 @@ export const AWSRoleCredentialsForm = ({
Specify which AWS credentials to use
</span>
<Select
name={ProviderCredentialFields.CREDENTIALS_TYPE}
label="Authentication Method"
placeholder="Select credentials type"
selectedKeys={[credentialsType || defaultCredentialsType]}
className="mb-4"
variant="bordered"
onSelectionChange={(keys) =>
setValue(
ProviderCredentialFields.CREDENTIALS_TYPE,
Array.from(keys)[0] as "aws-sdk-default" | "access-secret-key",
)
}
>
<SelectItem
key="aws-sdk-default"
textValue={
isCloudEnv
? "Prowler Cloud will assume your IAM role"
: "AWS SDK Default"
}
<div className="mb-4 flex flex-col gap-1.5">
<Select
value={credentialsType || defaultCredentialsType}
onValueChange={(value) => {
setValue(
ProviderCredentialFields.CREDENTIALS_TYPE,
value as "aws-sdk-default" | "access-secret-key",
);
}}
>
<div className="flex w-full items-center justify-between">
<span>
{isCloudEnv
? "Prowler Cloud will assume your IAM role"
: "AWS SDK Default"}
</span>
{isCloudEnv && (
<Chip size="sm" variant="flat" color="success" className="ml-2">
Recommended
</Chip>
)}
</div>
</SelectItem>
<SelectItem key="access-secret-key" textValue="Access & Secret Key">
<div className="flex w-full items-center justify-between">
<span>Access & Secret Key</span>
</div>
</SelectItem>
</Select>
<SelectTrigger>
<SelectValue placeholder="Select credentials type" />
</SelectTrigger>
<SelectContent className="z-[60]">
<SelectItem value="aws-sdk-default">
<div className="flex w-full items-center justify-between">
<span>
{isCloudEnv
? "Prowler Cloud will assume your IAM role"
: "AWS SDK Default"}
</span>
{isCloudEnv && (
<Chip
size="sm"
variant="flat"
color="success"
className="ml-2"
>
Recommended
</Chip>
)}
</div>
</SelectItem>
<SelectItem value="access-secret-key">
<div className="flex w-full items-center justify-between">
<span>Access & Secret Key</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{credentialsType === "access-secret-key" && (
<>

View File

@@ -12,8 +12,9 @@ import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { addRole } from "@/actions/roles/roles";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
import { CustomDropdownSelection, CustomInput } from "@/components/ui/custom";
import { CustomInput } from "@/components/ui/custom";
import { Form, FormButtons } from "@/components/ui/form";
import { getErrorMessage, permissionFormFields } from "@/lib";
import { addRoleFormSchema, ApiError } from "@/types";
@@ -232,15 +233,21 @@ export const AddRoleForm = ({
name="groups"
control={form.control}
render={({ field }) => (
<CustomDropdownSelection
label="Select Groups"
name="groups"
values={groups}
selectedKeys={field.value || []}
onChange={(name, selectedValues) =>
field.onChange(selectedValues)
}
/>
<div className="flex flex-col gap-2">
<EnhancedMultiSelect
options={groups.map((group) => ({
label: group.name,
value: group.id,
}))}
onValueChange={field.onChange}
defaultValue={field.value || []}
placeholder="Select groups"
searchable={true}
hideSelectAll={true}
emptyIndicator="No results found"
resetOnDefaultValueChange={true}
/>
</div>
)}
/>
{form.formState.errors.groups && (

View File

@@ -12,8 +12,9 @@ import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { updateRole } from "@/actions/roles/roles";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
import { CustomDropdownSelection, CustomInput } from "@/components/ui/custom";
import { CustomInput } from "@/components/ui/custom";
import { Form, FormButtons } from "@/components/ui/form";
import { getErrorMessage, permissionFormFields } from "@/lib";
import { ApiError, editRoleFormSchema } from "@/types";
@@ -250,15 +251,21 @@ export const EditRoleForm = ({
name="groups"
control={form.control}
render={({ field }) => (
<CustomDropdownSelection
label="Select Groups"
name="groups"
values={groups}
selectedKeys={field.value}
onChange={(name, selectedValues) => {
field.onChange(selectedValues);
}}
/>
<div className="flex flex-col gap-2">
<EnhancedMultiSelect
options={groups.map((group) => ({
label: group.name,
value: group.id,
}))}
onValueChange={field.onChange}
defaultValue={field.value || []}
placeholder="Select groups"
searchable={true}
hideSelectAll={true}
emptyIndicator="No results found"
resetOnDefaultValueChange={true}
/>
</div>
)}
/>

View File

@@ -17,6 +17,7 @@ const badgeVariants = cva(
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
tag: "bg-bg-tag border-border-tag text-text-neutral-primary",
},
},
defaultVariants: {

View File

@@ -66,7 +66,7 @@ function CommandInput({
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
className="border-border-neutral-primary flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input

View File

@@ -6,7 +6,7 @@ import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
function Drawer({ ...props }: ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
return <DrawerPrimitive.Root data-slot="drawer" handleOnly {...props} />;
}
function DrawerTrigger({

View File

@@ -81,8 +81,9 @@ export function ActionDropdownItem({
return (
<DropdownMenuItem
className={cn(
"flex cursor-pointer items-start gap-2",
destructive && "text-text-error-primary focus:text-text-error-primary",
"hover:bg-bg-neutral-tertiary flex cursor-pointer items-start gap-2 rounded-md transition-colors",
destructive &&
"text-text-error-primary focus:text-text-error-primary hover:bg-destructive/10",
className,
)}
{...props}

View File

@@ -20,16 +20,19 @@ function PopoverContent({
className,
align = "center",
sideOffset = 4,
container,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
container?: HTMLElement | null;
}) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Portal container={container ?? undefined}>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 pointer-events-auto z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}

View File

@@ -0,0 +1,404 @@
"use client";
import { ChevronDown, XCircle, XIcon } from "lucide-react";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Checkbox } from "@/components/shadcn/checkbox/checkbox";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/shadcn/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/shadcn/popover";
import { Separator } from "@/components/shadcn/separator/separator";
import { cn } from "@/lib/utils";
interface MultiSelectOption {
label: string;
value: string;
icon?: ReactNode;
description?: string;
disabled?: boolean;
}
interface EnhancedMultiSelectProps {
options: MultiSelectOption[];
onValueChange: (values: string[]) => void;
defaultValue?: string[];
placeholder?: string;
searchable?: boolean;
hideSelectAll?: boolean;
maxCount?: number;
closeOnSelect?: boolean;
resetOnDefaultValueChange?: boolean;
emptyIndicator?: ReactNode;
disabled?: boolean;
className?: string;
id?: string;
"aria-label"?: string;
}
function arraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((val, index) => val === sortedB[index]);
}
export function EnhancedMultiSelect({
options,
onValueChange,
defaultValue = [],
placeholder = "Select options",
searchable = true,
hideSelectAll = false,
maxCount = 3,
closeOnSelect = false,
resetOnDefaultValueChange = true,
emptyIndicator,
disabled = false,
className,
id,
"aria-label": ariaLabel,
}: EnhancedMultiSelectProps) {
const [selectedValues, setSelectedValues] = useState<string[]>(defaultValue);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(
null,
);
const buttonRef = useRef<HTMLButtonElement>(null);
const prevDefaultValueRef = useRef<string[]>(defaultValue);
const selectedAtOpenRef = useRef<string[]>(selectedValues);
const multiSelectId = useId();
const listboxId = `${multiSelectId}-listbox`;
// Detect dialog container for portal stacking (critical for Jira modal)
useEffect(() => {
if (!buttonRef.current) return;
const closestDialogContainer = buttonRef.current.closest(
"[data-slot='dialog-content'], [data-slot='modal-content'], [role='dialog']",
);
setPortalContainer(
closestDialogContainer instanceof HTMLElement
? closestDialogContainer
: null,
);
}, []);
// Reset when defaultValue changes externally (e.g. React Hook Form reset)
useEffect(() => {
if (!resetOnDefaultValueChange) return;
const prev = prevDefaultValueRef.current;
if (!arraysEqual(prev, defaultValue)) {
if (!arraysEqual(selectedValues, defaultValue)) {
setSelectedValues(defaultValue);
}
prevDefaultValueRef.current = [...defaultValue];
}
}, [defaultValue, selectedValues, resetOnDefaultValueChange]);
function handleOpenChange(nextOpen: boolean) {
if (nextOpen) {
selectedAtOpenRef.current = [...selectedValues];
} else {
setSearch("");
}
setOpen(nextOpen);
}
const enabledOptions = options.filter((o) => !o.disabled);
const filteredOptions = (
searchable && search
? options.filter(
(o) =>
o.label.toLowerCase().includes(search.toLowerCase()) ||
o.value.toLowerCase().includes(search.toLowerCase()),
)
: options
).toSorted((a, b) => {
const snapshot = selectedAtOpenRef.current;
const aSelected = snapshot.includes(a.value) ? 0 : 1;
const bSelected = snapshot.includes(b.value) ? 0 : 1;
return aSelected - bSelected;
});
function getOptionByValue(value: string) {
return options.find((o) => o.value === value);
}
function toggleOption(value: string) {
if (disabled) return;
const option = getOptionByValue(value);
if (option?.disabled) return;
const next = selectedValues.includes(value)
? selectedValues.filter((v) => v !== value)
: [...selectedValues, value];
setSelectedValues(next);
onValueChange(next);
if (closeOnSelect) setOpen(false);
}
function toggleAll() {
if (disabled) return;
if (selectedValues.length === enabledOptions.length) {
handleClear();
} else {
const all = enabledOptions.map((o) => o.value);
setSelectedValues(all);
onValueChange(all);
}
if (closeOnSelect) setOpen(false);
}
function handleClear() {
if (disabled) return;
setSelectedValues([]);
onValueChange([]);
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
id={id}
ref={buttonRef}
variant="outline"
onClick={() => !disabled && setOpen((prev) => !prev)}
disabled={disabled}
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
aria-controls={open ? listboxId : undefined}
aria-label={ariaLabel}
className={cn(
"border-border-input-primary bg-bg-input-primary text-text-neutral-primary data-[placeholder]:text-text-neutral-tertiary [&_svg:not([class*='text-'])]:text-text-neutral-tertiary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-input-primary active:bg-bg-input-primary focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex h-auto min-h-12 w-full items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 [&_svg]:pointer-events-auto",
disabled && "cursor-not-allowed opacity-50",
className,
)}
>
{selectedValues.length > 0 ? (
<div className="flex w-full items-center justify-between">
<div className="flex flex-wrap items-center gap-1">
{selectedValues
.slice(0, maxCount)
.map((value) => {
const option = getOptionByValue(value);
if (!option) return null;
return (
<Badge
key={value}
variant="tag"
className="m-1 cursor-default [&>svg]:pointer-events-auto"
>
<span className="cursor-default">{option.label}</span>
<span
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
toggleOption(value);
}}
aria-label={`Remove ${option.label} from selection`}
className="focus:ring-border-input-primary-press -m-0.5 ml-2 inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center self-center rounded-sm p-0.5 focus:ring-1 focus:outline-none"
>
<XCircle className="h-3 w-3" />
</span>
</Badge>
);
})
.filter(Boolean)}
{selectedValues.length > maxCount && (
<Badge
variant="tag"
className="m-1 cursor-default [&>svg]:pointer-events-auto"
>
{`+ ${selectedValues.length - maxCount} more`}
<span
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
const trimmed = selectedValues.slice(0, maxCount);
setSelectedValues(trimmed);
onValueChange(trimmed);
}}
className="ml-2 inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center self-center rounded-sm"
aria-label="Clear extra selected options"
>
<XCircle className="h-3 w-3" />
</span>
</Badge>
)}
</div>
<div className="flex items-center justify-between">
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
handleClear();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
handleClear();
}
}}
aria-label={`Clear all ${selectedValues.length} selected options`}
className="text-text-neutral-tertiary hover:text-text-neutral-primary focus:ring-border-input-primary-press mx-2 flex h-4 w-4 cursor-pointer items-center justify-center rounded-sm focus:ring-2 focus:ring-offset-1 focus:outline-none"
>
<XIcon className="h-4 w-4" />
</div>
<Separator
orientation="vertical"
className="flex h-full min-h-6"
/>
<ChevronDown
className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer"
aria-hidden="true"
/>
</div>
</div>
) : (
<div className="mx-auto flex w-full items-center justify-between">
<span className="text-text-neutral-tertiary mx-3 text-sm">
{placeholder}
</span>
<ChevronDown className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer" />
</div>
)}
</Button>
</PopoverTrigger>
<PopoverContent
container={portalContainer}
id={listboxId}
role="listbox"
aria-multiselectable="true"
aria-label="Available options"
className="border-border-input-primary bg-bg-input-primary text-text-neutral-primary pointer-events-auto z-50 w-[var(--radix-popover-trigger-width)] max-w-[var(--radix-popover-trigger-width)] touch-manipulation rounded-lg p-0"
align="start"
onEscapeKeyDown={() => setOpen(false)}
>
<Command>
{searchable && (
<CommandInput
placeholder="Search options..."
value={search}
onValueChange={setSearch}
aria-label="Search through available options"
/>
)}
<CommandList className="minimal-scrollbar multiselect-scrollbar max-h-[40vh] overflow-x-hidden overflow-y-auto overscroll-y-contain">
<CommandEmpty>{emptyIndicator || "No results found."}</CommandEmpty>
{!hideSelectAll && !search && (
<CommandGroup>
<CommandItem
key="all"
onSelect={toggleAll}
role="option"
aria-selected={
selectedValues.length === enabledOptions.length
}
className="cursor-pointer"
>
<Checkbox
checked={selectedValues.length === enabledOptions.length}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none mr-2 size-4"
/>
<span>Select All</span>
</CommandItem>
</CommandGroup>
)}
<CommandGroup>
{filteredOptions.map((option) => {
const isSelected = selectedValues.includes(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => toggleOption(option.value)}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
className={cn(
"cursor-pointer",
option.disabled && "cursor-not-allowed opacity-50",
)}
disabled={option.disabled}
>
<Checkbox
checked={isSelected}
disabled={option.disabled}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none mr-2 size-4"
/>
{option.icon && (
<span className="shrink-0">{option.icon}</span>
)}
<div className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
{option.description && (
<span className="text-text-neutral-tertiary text-xs">
{option.description}
</span>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
<Separator />
<div className="flex items-center justify-between p-1">
{selectedValues.length > 0 && (
<>
<Button
variant="ghost"
size="sm"
onClick={handleClear}
className="flex-1"
>
Clear
</Button>
<Separator
orientation="vertical"
className="flex h-full min-h-6"
/>
</>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setOpen(false)}
className="flex-1"
>
Close
</Button>
</div>
</Command>
</PopoverContent>
</Popover>
);
}
EnhancedMultiSelect.displayName = "EnhancedMultiSelect";
export type { EnhancedMultiSelectProps, MultiSelectOption };

View File

@@ -224,9 +224,9 @@ export function MultiSelectValue({
.filter((value) => items.has(value))
.map((value) => (
<Badge
variant="outline"
variant="tag"
data-selected-item
className="text-bg-button-secondary group flex items-center gap-1.5 border-slate-300 bg-slate-100 px-2 py-1 text-xs font-medium dark:border-slate-600 dark:bg-slate-800"
className="group flex items-center gap-1.5 px-2 py-1 text-xs font-medium"
key={value}
onClick={
clickToRemove
@@ -239,7 +239,7 @@ export function MultiSelectValue({
>
{items.get(value)}
{clickToRemove && (
<XIcon className="text-bg-button-secondary group-hover:text-destructive size-3 transition-colors" />
<XIcon className="text-text-neutral-primary group-hover:text-destructive size-3 transition-colors" />
)}
</Badge>
))}
@@ -247,9 +247,9 @@ export function MultiSelectValue({
style={{
display: overflowAmount > 0 && !shouldWrap ? "block" : "none",
}}
variant="outline"
variant="tag"
ref={overflowRef}
className="text-bg-button-secondary border-slate-300 bg-slate-100 px-2 py-1 text-xs font-medium dark:border-slate-600 dark:bg-slate-800"
className="px-2 py-1 text-xs font-medium"
>
+{overflowAmount}
</Badge>

View File

@@ -1,53 +0,0 @@
"use client";
import React, { useCallback } from "react";
import {
MultiSelect,
MultiSelectContent,
MultiSelectItem,
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
interface CustomDropdownSelectionProps {
label: string;
name: string;
values: { id: string; name: string }[];
onChange: (name: string, selectedValues: string[]) => void;
selectedKeys?: string[];
}
export const CustomDropdownSelection: React.FC<
CustomDropdownSelectionProps
> = ({ label, name, values, onChange, selectedKeys = [] }) => {
const handleValuesChange = useCallback(
(newValues: string[]) => {
onChange(name, newValues);
},
[name, onChange],
);
return (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">{label}</p>
<MultiSelect values={selectedKeys} onValuesChange={handleValuesChange}>
<MultiSelectTrigger>
<MultiSelectValue placeholder={`Select ${label.toLowerCase()}`} />
</MultiSelectTrigger>
<MultiSelectContent
search={{
placeholder: `Search ${label.toLowerCase()}...`,
emptyMessage: "No results found",
}}
>
{values.map((item) => (
<MultiSelectItem key={item.id} value={item.id}>
{item.name}
</MultiSelectItem>
))}
</MultiSelectContent>
</MultiSelect>
</div>
);
};

View File

@@ -1,5 +1,4 @@
export * from "./custom-banner";
export * from "./custom-dropdown-selection";
export * from "./custom-input";
export * from "./custom-link";
export * from "./custom-modal-buttons";

View File

@@ -1,6 +1,5 @@
"use client";
import { Select, SelectItem } from "@heroui/select";
import { zodResolver } from "@hookform/resolvers/zod";
import { ShieldIcon, UserIcon } from "lucide-react";
import { Dispatch, SetStateAction } from "react";
@@ -9,6 +8,13 @@ import * as z from "zod";
import { updateUser, updateUserRole } from "@/actions/users/users";
import { Card } from "@/components/shadcn";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { Form, FormButtons } from "@/components/ui/form";
@@ -168,29 +174,22 @@ export const EditForm = ({
/>
</div>
<div>
<div className="flex flex-col gap-1.5">
<Controller
name="role"
control={form.control}
render={({ field }) => (
<Select
{...field}
label="Role"
labelPlacement="outside"
placeholder="Select a role"
classNames={{
selectorIcon: "right-2",
}}
variant="bordered"
selectedKeys={[field.value || ""]}
onSelectionChange={(selected) => {
const selectedKey = Array.from(selected).pop();
field.onChange(selectedKey || "");
}}
>
{roles.map((role: { id: string; name: string }) => (
<SelectItem key={role.id}>{role.name}</SelectItem>
))}
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((role: { id: string; name: string }) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>

View File

@@ -396,3 +396,10 @@
@apply bg-background text-foreground;
}
}
/* Override vaul's injected user-select: none to allow text selection in drawers */
@media (hover: hover) and (pointer: fine) {
[data-vaul-drawer][data-vaul-drawer] {
user-select: text;
}
}

View File

@@ -34,7 +34,7 @@ export class InvitationsPage extends BasePage {
this.emailInput = page.getByRole("textbox", { name: "Email" });
// Form select
this.roleSelect = page.getByRole("button", { name: /Role|Select a role/i });
this.roleSelect = page.getByRole("combobox", { name: /Role|Select a role/i });
// Form details
this.reviewInvitationDetailsButton = page.getByRole('button', { name: /Review Invitation Details/i });

View File

@@ -990,19 +990,16 @@ export class ProvidersPage extends BasePage {
}
async selectAuthenticationMethod(method: AWSCredentialType): Promise<void> {
// Select the authentication method
// Select the authentication method (shadcn Select renders as combobox + listbox)
const button = this.page.locator("button").filter({
const trigger = this.page.locator('[role="combobox"]').filter({
hasText: /AWS SDK Default|Prowler Cloud will assume|Access & Secret Key/i,
});
await button.click();
await trigger.click();
const modal = this.page
.locator('[role="dialog"], .modal, [data-testid*="modal"]')
.first();
await expect(modal).toBeVisible({ timeout: 10000 });
const listbox = this.page.getByRole("listbox");
await expect(listbox).toBeVisible({ timeout: 10000 });
if (method === AWS_CREDENTIAL_OPTIONS.AWS_ROLE_ARN) {
await this.page