mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-31 16:14:46 +02:00
Update create client form
This commit is contained in:
parent
bcd80e19d4
commit
875fa215c5
5 changed files with 222 additions and 72 deletions
73
package-lock.json
generated
73
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,45 +265,38 @@ 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>
|
||||
<ScrollArea className="h-[200px]">
|
||||
{sites.map((site) => (
|
||||
<CommandItem
|
||||
value={`${site.siteId}:${site.name}:${site.niceId}`}
|
||||
key={site.siteId}
|
||||
onSelect={() => {
|
||||
form.setValue(
|
||||
"siteId",
|
||||
site.siteId
|
||||
);
|
||||
addSite(site.siteId, site.name);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
site.siteId ===
|
||||
field.value
|
||||
selectedSites.some(s => s.id === site.siteId)
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
|
@ -293,15 +304,32 @@ export default function CreateClientForm({
|
|||
{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>
|
||||
|
|
|
@ -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>
|
||||
|
|
48
src/components/ui/scroll-area.tsx
Normal file
48
src/components/ui/scroll-area.tsx
Normal 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 }
|
Loading…
Add table
Add a link
Reference in a new issue