mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-22 03:08:23 +00:00
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:
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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" && (
|
||||
<>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
404
ui/components/shadcn/select/enhanced-multi-select.tsx
Normal file
404
ui/components/shadcn/select/enhanced-multi-select.tsx
Normal 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 };
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user