Update create client form

This commit is contained in:
Owen 2025-03-31 16:27:40 -04:00
parent bcd80e19d4
commit 875fa215c5
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
5 changed files with 222 additions and 72 deletions

73
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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<typeof createClientFormSchema>;
type CreateClientFormValues = z.infer<typeof createClientFormSchema>;
const defaultValues: Partial<CreateSiteFormValues> = {
name: ""
const defaultValues: Partial<CreateClientFormValues> = {
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<PickClientDefaultsResponse | null>(null);
const [olmCommand, setOlmCommand] = useState<string | null>(null);
const [selectedSites, setSelectedSites] = useState<Array<{id: number, name: string}>>([]);
const handleCheckboxChange = (checked: boolean) => {
setIsChecked(checked);
@ -95,11 +101,16 @@ export default function CreateClientForm({
}
};
const form = useForm<CreateSiteFormValues>({
const form = useForm<CreateClientFormValues>({
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<AxiosResponse<ListSitesResponse>>(
@ -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<CreateClientResponse>
>(`/site/${data.siteId}/client`, payload)
.put<AxiosResponse<CreateClientResponse>>(
`/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({
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="create-site-form"
id="create-client-form"
>
<FormField
control={form.control}
@ -235,10 +253,10 @@ export default function CreateClientForm({
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
name="siteIds"
render={() => (
<FormItem className="flex flex-col">
<FormLabel>Site</FormLabel>
<FormLabel>Sites</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@ -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"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<PopoverContent className="p-0 w-[300px]">
<Command>
<CommandInput placeholder="Search site..." />
<CommandInput placeholder="Search sites..." />
<CommandList>
<CommandEmpty>
No site found.
No sites found.
</CommandEmpty>
<CommandGroup>
{sites.map((site) => (
<CommandItem
value={`${site.siteId}:${site.name}:${site.niceId}`}
key={site.siteId}
onSelect={() => {
form.setValue(
"siteId",
site.siteId
);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
<ScrollArea className="h-[200px]">
{sites.map((site) => (
<CommandItem
value={`${site.siteId}:${site.name}:${site.niceId}`}
key={site.siteId}
onSelect={() => {
addSite(site.siteId, site.name);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
selectedSites.some(s => s.id === site.siteId)
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</ScrollArea>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedSites.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{selectedSites.map(site => (
<Badge key={site.id} variant="secondary">
{site.name}
<button
type="button"
onClick={() => removeSite(site.id)}
className="ml-1 rounded-full outline-none focus:ring-2 focus:ring-offset-2"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<FormDescription>
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.
</FormDescription>
<FormMessage />
</FormItem>
@ -342,4 +370,4 @@ export default function CreateClientForm({
</Form>
</div>
);
}
}

View file

@ -44,7 +44,7 @@ export default function CreateClientFormModal({
<CredenzaHeader>
<CredenzaTitle>Create Client</CredenzaTitle>
<CredenzaDescription>
Create a new client to connect to your site
Create a new client to connect to your sites
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>

View file

@ -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<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }