diff --git a/package-lock.json b/package-lock.json index c9e0a334..81fc0c38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@radix-ui/react-label": "2.1.1", "@radix-ui/react-popover": "1.1.4", "@radix-ui/react-radio-group": "1.2.2", + "@radix-ui/react-scroll-area": "1.2.3", "@radix-ui/react-select": "2.1.4", "@radix-ui/react-separator": "1.1.1", "@radix-ui/react-slot": "1.1.1", @@ -3536,6 +3537,78 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.3.tgz", + "integrity": "sha512-l7+NNBfBYYJa9tNqVcP2AGvxdE3lmE6kFTBXdvHgUaZuy+4wGCL1Cl2AfaR7RKyimj7lZURGLwFO59k4eBnDJQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.4.tgz", diff --git a/package.json b/package.json index 08cb73aa..b791d980 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@radix-ui/react-label": "2.1.1", "@radix-ui/react-popover": "1.1.4", "@radix-ui/react-radio-group": "1.2.2", + "@radix-ui/react-scroll-area": "1.2.3", "@radix-ui/react-select": "2.1.4", "@radix-ui/react-separator": "1.1.1", "@radix-ui/react-slot": "1.1.1", diff --git a/src/app/[orgId]/settings/clients/CreateClientsForm.tsx b/src/app/[orgId]/settings/clients/CreateClientsForm.tsx index 09bdb7f9..77275471 100644 --- a/src/app/[orgId]/settings/clients/CreateClientsForm.tsx +++ b/src/app/[orgId]/settings/clients/CreateClientsForm.tsx @@ -21,7 +21,6 @@ import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { AxiosResponse } from "axios"; -import { Collapsible } from "@app/components/ui/collapsible"; import { ClientRow } from "./ClientsTable"; import { CreateClientBody, @@ -45,6 +44,9 @@ import { CommandItem, CommandList } from "@app/components/ui/command"; +import { ScrollArea } from "@app/components/ui/scroll-area"; +import { Badge } from "@app/components/ui/badge"; +import { X } from "lucide-react"; const createClientFormSchema = z.object({ name: z @@ -55,16 +57,19 @@ const createClientFormSchema = z.object({ .max(30, { message: "Name must not be longer than 30 characters." }), - siteId: z.coerce.number() + siteIds: z.array(z.number()).min(1, { + message: "Select at least one site." + }) }); -type CreateSiteFormValues = z.infer; +type CreateClientFormValues = z.infer; -const defaultValues: Partial = { - name: "" +const defaultValues: Partial = { + name: "", + siteIds: [] }; -type CreateSiteFormProps = { +type CreateClientFormProps = { onCreate?: (client: ClientRow) => void; setLoading?: (loading: boolean) => void; setChecked?: (checked: boolean) => void; @@ -76,7 +81,7 @@ export default function CreateClientForm({ setLoading, setChecked, orgId -}: CreateSiteFormProps) { +}: CreateClientFormProps) { const api = createApiClient(useEnvContext()); const { env } = useEnvContext(); @@ -87,6 +92,7 @@ export default function CreateClientForm({ const [clientDefaults, setClientDefaults] = useState(null); const [olmCommand, setOlmCommand] = useState(null); + const [selectedSites, setSelectedSites] = useState>([]); const handleCheckboxChange = (checked: boolean) => { setIsChecked(checked); @@ -95,11 +101,16 @@ export default function CreateClientForm({ } }; - const form = useForm({ + const form = useForm({ resolver: zodResolver(createClientFormSchema), defaultValues }); + useEffect(() => { + // Update form value when selectedSites changes + form.setValue('siteIds', selectedSites.map(site => site.id)); + }, [selectedSites, form]); + useEffect(() => { if (!open) return; @@ -109,6 +120,7 @@ export default function CreateClientForm({ form.reset(); setChecked?.(false); setClientDefaults(null); + setSelectedSites([]); const fetchSites = async () => { const res = await api.get>( @@ -118,25 +130,19 @@ export default function CreateClientForm({ (s) => s.type === "newt" && s.subnet ); setSites(sites); - - if (sites.length > 0) { - form.setValue("siteId", sites[0].siteId); - } }; fetchSites(); }, [open]); useEffect(() => { - const siteId = form.getValues("siteId"); + if (selectedSites.length === 0) return; - if (siteId === undefined || siteId === null) return; - - api.get(`/site/${siteId}/pick-client-defaults`) + api.get(`/pick-client-defaults`) .catch((e) => { toast({ variant: "destructive", - title: `Error fetching client defaults for site ${siteId}`, + title: `Error fetching client defaults`, description: formatAxiosError(e) }); }) @@ -148,17 +154,27 @@ export default function CreateClientForm({ setOlmCommand(olmConfig); } }); - }, [form.watch("siteId")]); + }, [selectedSites]); - async function onSubmit(data: CreateSiteFormValues) { + const addSite = (siteId: number, siteName: string) => { + if (!selectedSites.some(site => site.id === siteId)) { + setSelectedSites([...selectedSites, { id: siteId, name: siteName }]); + } + }; + + const removeSite = (siteId: number) => { + setSelectedSites(selectedSites.filter(site => site.id !== siteId)); + }; + + async function onSubmit(data: CreateClientFormValues) { setLoading?.(true); setIsLoading(true); if (!clientDefaults) { toast({ variant: "destructive", - title: "Error creating site", - description: "Site defaults not found" + title: "Error creating client", + description: "Client defaults not found" }); setLoading?.(false); setIsLoading(false); @@ -167,17 +183,17 @@ export default function CreateClientForm({ const payload = { name: data.name, - siteId: data.siteId, - subnet: clientDefaults.subnet, + siteIds: data.siteIds, olmId: clientDefaults.olmId, secret: clientDefaults.olmSecret, type: "olm" } as CreateClientBody; const res = await api - .put< - AxiosResponse - >(`/site/${data.siteId}/client`, payload) + .put>( + `/org/${orgId}/client`, + payload + ) .catch((e) => { toast({ variant: "destructive", @@ -189,12 +205,14 @@ export default function CreateClientForm({ if (res && res.status === 201) { const data = res.data.data; - const site = sites.find((site) => site.siteId === data.siteId); + // For now we'll just use the first site for display purposes + // The actual client will be associated with all selected sites + const firstSite = sites.find((site) => site.siteId === selectedSites[0]?.id); onCreate?.({ name: data.name, - siteId: site!.niceId, - siteName: site!.name, + siteId: firstSite?.niceId || "", + siteName: firstSite?.name || "", id: data.clientId, mbIn: "0 MB", mbOut: "0 MB", @@ -213,7 +231,7 @@ export default function CreateClientForm({
( + name="siteIds" + render={() => ( - Site + Sites @@ -247,61 +265,71 @@ export default function CreateClientForm({ role="combobox" className={cn( "justify-between", - !field.value && + selectedSites.length === 0 && "text-muted-foreground" )} > - {field.value - ? sites.find( - (site) => - site.siteId === - field.value - )?.name - : "Select site"} + {selectedSites.length > 0 + ? `${selectedSites.length} site${selectedSites.length !== 1 ? 's' : ''} selected` + : "Select sites"} - + - + - No site found. + No sites found. - {sites.map((site) => ( - { - form.setValue( - "siteId", - site.siteId - ); - }} - > - - {site.name} - - ))} + + {sites.map((site) => ( + { + addSite(site.siteId, site.name); + }} + > + s.id === site.siteId) + ? "opacity-100" + : "opacity-0" + )} + /> + {site.name} + + ))} + + + {selectedSites.length > 0 && ( +
+ {selectedSites.map(site => ( + + {site.name} + + + ))} +
+ )} + - The client will be have connectivity to this - site. The site must be configured to accept - client connections. + The client will have connectivity to the selected sites. The sites must be configured to accept client connections.
@@ -342,4 +370,4 @@ export default function CreateClientForm({ ); -} +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/clients/CreateClientsModal.tsx b/src/app/[orgId]/settings/clients/CreateClientsModal.tsx index ef6df0f3..450e655f 100644 --- a/src/app/[orgId]/settings/clients/CreateClientsModal.tsx +++ b/src/app/[orgId]/settings/clients/CreateClientsModal.tsx @@ -44,7 +44,7 @@ export default function CreateClientFormModal({ Create Client - Create a new client to connect to your site + Create a new client to connect to your sites diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 00000000..0b4a48d8 --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar }