mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-30 14:36:46 +00:00
♻️ make machine selector a multi-combobox
This commit is contained in:
@@ -148,6 +148,11 @@
|
||||
"createLink": "Create Link",
|
||||
"resourcesNotFound": "No resources found",
|
||||
"resourceSearch": "Search resources",
|
||||
"machineSearch": "Search machines",
|
||||
"machinesSearch": "Search machine clients...",
|
||||
"machineNotFound": "No machines found",
|
||||
"userDeviceSearch": "Search user devices",
|
||||
"userDevicesSearch": "Search user devices...",
|
||||
"openMenu": "Open menu",
|
||||
"resource": "Resource",
|
||||
"title": "Title",
|
||||
|
||||
@@ -217,11 +217,6 @@ export default function CreateShareLinkForm({
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// function getSelectedResourceName(id: number) {
|
||||
// const resource = resources.find((r) => r.resourceId === id);
|
||||
// return `${resource?.name}`;
|
||||
// }
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
@@ -279,60 +274,6 @@ export default function CreateShareLinkForm({
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
{/* <Command>
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"resourceSearch"
|
||||
)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t(
|
||||
"resourcesNotFound"
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{resources.map(
|
||||
(
|
||||
r
|
||||
) => (
|
||||
<CommandItem
|
||||
value={`${r.name}:${r.resourceId}`}
|
||||
key={
|
||||
r.resourceId
|
||||
}
|
||||
onSelect={() => {
|
||||
form.setValue(
|
||||
"resourceId",
|
||||
r.resourceId
|
||||
);
|
||||
form.setValue(
|
||||
"resourceName",
|
||||
r.name
|
||||
);
|
||||
form.setValue(
|
||||
"resourceUrl",
|
||||
r.resourceUrl
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
r.resourceId ===
|
||||
field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{`${r.name}`}
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command> */}
|
||||
|
||||
<ResourceSelector
|
||||
orgId={
|
||||
org.org
|
||||
|
||||
@@ -43,6 +43,8 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { SitesSelector, type Selectedsite } from "./site-selector";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
import { MachineSelector } from "./machine-selector";
|
||||
|
||||
// --- Helpers (shared) ---
|
||||
|
||||
@@ -243,7 +245,14 @@ export function InternalResourceForm({
|
||||
authDaemonPort: z.number().int().positive().optional().nullable(),
|
||||
roles: z.array(tagSchema).optional(),
|
||||
users: z.array(tagSchema).optional(),
|
||||
clients: z.array(tagSchema).optional()
|
||||
clients: z
|
||||
.array(
|
||||
z.object({
|
||||
clientId: z.number(),
|
||||
name: z.string()
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
@@ -252,7 +261,7 @@ export function InternalResourceForm({
|
||||
|
||||
const rolesQuery = useQuery(orgQueries.roles({ orgId }));
|
||||
const usersQuery = useQuery(orgQueries.users({ orgId }));
|
||||
const clientsQuery = useQuery(orgQueries.clients({ orgId }));
|
||||
const clientsQuery = useQuery(orgQueries.machineClients({ orgId }));
|
||||
const resourceRolesQuery = useQuery({
|
||||
...resourceQueries.siteResourceRoles({
|
||||
siteResourceId: siteResourceId ?? 0
|
||||
@@ -310,12 +319,9 @@ export function InternalResourceForm({
|
||||
}));
|
||||
}
|
||||
if (clientsData) {
|
||||
existingClients = (
|
||||
clientsData as { clientId: number; name: string }[]
|
||||
).map((c) => ({
|
||||
id: c.clientId.toString(),
|
||||
text: c.name
|
||||
}));
|
||||
existingClients = [
|
||||
...(clientsData as { clientId: number; name: string }[])
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,8 +598,7 @@ export function InternalResourceForm({
|
||||
</div>
|
||||
|
||||
<HorizontalTabs
|
||||
clientSide={true}
|
||||
defaultTab={0}
|
||||
clientSide
|
||||
items={[
|
||||
{
|
||||
title: t(
|
||||
@@ -610,7 +615,7 @@ export function InternalResourceForm({
|
||||
: [{ title: t("sshAccess"), href: "#" }])
|
||||
]}
|
||||
>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-4 mt-4 p-1">
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<label className="font-medium block">
|
||||
@@ -981,7 +986,7 @@ export function InternalResourceForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-4 mt-4 p-1">
|
||||
<div className="mb-8">
|
||||
<label className="font-medium block">
|
||||
{t("editInternalResourceDialogAccessControl")}
|
||||
@@ -1101,48 +1106,73 @@ export function InternalResourceForm({
|
||||
<FormLabel>
|
||||
{t("machineClients")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeClientsTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveClientsTagIndex
|
||||
}
|
||||
placeholder={
|
||||
t(
|
||||
"accessClientSelect"
|
||||
) ||
|
||||
"Select machine clients"
|
||||
}
|
||||
size="sm"
|
||||
tags={
|
||||
form.getValues()
|
||||
.clients ?? []
|
||||
}
|
||||
setTags={(newClients) =>
|
||||
form.setValue(
|
||||
"clients",
|
||||
newClients as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
)
|
||||
}
|
||||
enableAutocomplete={
|
||||
true
|
||||
}
|
||||
autocompleteOptions={
|
||||
allClients
|
||||
}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between w-full",
|
||||
"text-muted-foreground pl-1.5"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1",
|
||||
"overflow-x-auto"
|
||||
)}
|
||||
>
|
||||
{(
|
||||
field.value ??
|
||||
[]
|
||||
).map(
|
||||
(
|
||||
client
|
||||
) => (
|
||||
<span
|
||||
key={
|
||||
client.clientId
|
||||
}
|
||||
className={cn(
|
||||
"bg-muted-foreground/20 font-normal text-foreground rounded-sm",
|
||||
"py-1 px-1.5 text-xs"
|
||||
)}
|
||||
>
|
||||
{
|
||||
client.name
|
||||
}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<span className="pl-1">
|
||||
{t(
|
||||
"accessClientSelect"
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<MachineSelector
|
||||
selectedMachines={
|
||||
field.value ??
|
||||
[]
|
||||
}
|
||||
orgId={orgId}
|
||||
onSelectMachines={(
|
||||
machines
|
||||
) => {
|
||||
form.setValue(
|
||||
"clients",
|
||||
machines
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -1154,7 +1184,7 @@ export function InternalResourceForm({
|
||||
|
||||
{/* SSH Access tab */}
|
||||
{!disableEnterpriseFeatures && mode !== "cidr" && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-4 mt-4 p-1">
|
||||
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
|
||||
<div className="mb-8">
|
||||
<label className="font-medium block">
|
||||
|
||||
@@ -540,7 +540,7 @@ export default function MachineClientsTable({
|
||||
columns={columns}
|
||||
rows={machineClients}
|
||||
tableId="machine-clients"
|
||||
searchPlaceholder={t("resourcesSearch")}
|
||||
searchPlaceholder={t("machinesSearch")}
|
||||
onAdd={() =>
|
||||
startNavigation(() =>
|
||||
router.push(`/${orgId}/settings/clients/machine/create`)
|
||||
|
||||
@@ -770,7 +770,7 @@ export default function UserDevicesTable({
|
||||
columns={columns}
|
||||
rows={userClients || []}
|
||||
tableId="user-clients"
|
||||
searchPlaceholder={t("resourcesSearch")}
|
||||
searchPlaceholder={t("userDevicesSearch")}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
enableColumnVisibility
|
||||
|
||||
108
src/components/machine-selector.tsx
Normal file
108
src/components/machine-selector.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import type { ListClientsResponse } from "@server/routers/client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "./ui/command";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type SelectedMachine = Pick<
|
||||
ListClientsResponse["clients"][number],
|
||||
"name" | "clientId"
|
||||
>;
|
||||
|
||||
export type MachineSelectorProps = {
|
||||
orgId: string;
|
||||
selectedMachines?: SelectedMachine[];
|
||||
onSelectMachines: (machine: SelectedMachine[]) => void;
|
||||
};
|
||||
|
||||
export function MachineSelector({
|
||||
orgId,
|
||||
selectedMachines = [],
|
||||
onSelectMachines
|
||||
}: MachineSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [machineSearchQuery, setMachineSearchQuery] = useState("");
|
||||
|
||||
const [debouncedValue] = useDebounce(machineSearchQuery, 150);
|
||||
|
||||
const { data: machines = [] } = useQuery(
|
||||
orgQueries.machineClients({ orgId, perPage: 10, query: debouncedValue })
|
||||
);
|
||||
|
||||
// always include the selected site in the list of sites shown
|
||||
const machinesShown = useMemo(() => {
|
||||
const allMachines: Array<SelectedMachine> = [...machines];
|
||||
for (const machine of selectedMachines) {
|
||||
if (
|
||||
!allMachines.find(
|
||||
(machine) => machine.clientId === machine.clientId
|
||||
)
|
||||
) {
|
||||
allMachines.unshift(machine);
|
||||
}
|
||||
}
|
||||
|
||||
return allMachines;
|
||||
}, [machines, selectedMachines]);
|
||||
|
||||
const selectedMachinesIds = new Set(
|
||||
selectedMachines.map((m) => m.clientId)
|
||||
);
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("machineSearch")}
|
||||
value={machineSearchQuery}
|
||||
onValueChange={setMachineSearchQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("machineNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{machinesShown.map((m) => (
|
||||
<CommandItem
|
||||
value={`${m.name}:${m.clientId}`}
|
||||
key={m.clientId}
|
||||
onSelect={() => {
|
||||
let newMachineClients = [];
|
||||
if (selectedMachinesIds.has(m.clientId)) {
|
||||
newMachineClients = selectedMachines.filter(
|
||||
(mc) => mc.clientId !== m.clientId
|
||||
);
|
||||
} else {
|
||||
newMachineClients = [
|
||||
...selectedMachines,
|
||||
m
|
||||
];
|
||||
}
|
||||
onSelectMachines(newMachineClients);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedMachinesIds.has(m.clientId)
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{`${m.name}`}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "./ui/command";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
@@ -44,6 +44,21 @@ export function ResourceSelector({
|
||||
})
|
||||
);
|
||||
|
||||
// always include the selected site in the list of sites shown
|
||||
const resourcesShown = useMemo(() => {
|
||||
const allResources: Array<SelectedResource> = [...resources];
|
||||
if (
|
||||
selectedResource &&
|
||||
!allResources.find(
|
||||
(resource) =>
|
||||
resource.resourceId === selectedResource?.resourceId
|
||||
)
|
||||
) {
|
||||
allResources.unshift(selectedResource);
|
||||
}
|
||||
return allResources;
|
||||
}, [resources, selectedResource]);
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
@@ -54,7 +69,7 @@ export function ResourceSelector({
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("resourcesNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{resources.map((r) => (
|
||||
{resourcesShown.map((r) => (
|
||||
<CommandItem
|
||||
value={`${r.name}:${r.resourceId}`}
|
||||
key={r.resourceId}
|
||||
|
||||
@@ -92,14 +92,26 @@ export const productUpdatesQueries = {
|
||||
};
|
||||
|
||||
export const orgQueries = {
|
||||
clients: ({ orgId }: { orgId: string }) =>
|
||||
machineClients: ({
|
||||
orgId,
|
||||
query,
|
||||
perPage = 10_000
|
||||
}: {
|
||||
orgId: string;
|
||||
query?: string;
|
||||
perPage?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "CLIENTS"] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const sp = new URLSearchParams({
|
||||
pageSize: "10000"
|
||||
pageSize: perPage.toString()
|
||||
});
|
||||
|
||||
if (query?.trim()) {
|
||||
sp.set("query", query);
|
||||
}
|
||||
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListClientsResponse>
|
||||
>(`/org/${orgId}/clients?${sp.toString()}`, { signal });
|
||||
|
||||
Reference in New Issue
Block a user