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",
|
"createLink": "Create Link",
|
||||||
"resourcesNotFound": "No resources found",
|
"resourcesNotFound": "No resources found",
|
||||||
"resourceSearch": "Search resources",
|
"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",
|
"openMenu": "Open menu",
|
||||||
"resource": "Resource",
|
"resource": "Resource",
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
|
|||||||
@@ -217,11 +217,6 @@ export default function CreateShareLinkForm({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// function getSelectedResourceName(id: number) {
|
|
||||||
// const resource = resources.find((r) => r.resourceId === id);
|
|
||||||
// return `${resource?.name}`;
|
|
||||||
// }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Credenza
|
<Credenza
|
||||||
@@ -279,60 +274,6 @@ export default function CreateShareLinkForm({
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0">
|
<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
|
<ResourceSelector
|
||||||
orgId={
|
orgId={
|
||||||
org.org
|
org.org
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { SitesSelector, type Selectedsite } from "./site-selector";
|
import { SitesSelector, type Selectedsite } from "./site-selector";
|
||||||
|
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||||
|
import { MachineSelector } from "./machine-selector";
|
||||||
|
|
||||||
// --- Helpers (shared) ---
|
// --- Helpers (shared) ---
|
||||||
|
|
||||||
@@ -243,7 +245,14 @@ export function InternalResourceForm({
|
|||||||
authDaemonPort: z.number().int().positive().optional().nullable(),
|
authDaemonPort: z.number().int().positive().optional().nullable(),
|
||||||
roles: z.array(tagSchema).optional(),
|
roles: z.array(tagSchema).optional(),
|
||||||
users: 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>;
|
type FormData = z.infer<typeof formSchema>;
|
||||||
@@ -252,7 +261,7 @@ export function InternalResourceForm({
|
|||||||
|
|
||||||
const rolesQuery = useQuery(orgQueries.roles({ orgId }));
|
const rolesQuery = useQuery(orgQueries.roles({ orgId }));
|
||||||
const usersQuery = useQuery(orgQueries.users({ orgId }));
|
const usersQuery = useQuery(orgQueries.users({ orgId }));
|
||||||
const clientsQuery = useQuery(orgQueries.clients({ orgId }));
|
const clientsQuery = useQuery(orgQueries.machineClients({ orgId }));
|
||||||
const resourceRolesQuery = useQuery({
|
const resourceRolesQuery = useQuery({
|
||||||
...resourceQueries.siteResourceRoles({
|
...resourceQueries.siteResourceRoles({
|
||||||
siteResourceId: siteResourceId ?? 0
|
siteResourceId: siteResourceId ?? 0
|
||||||
@@ -310,12 +319,9 @@ export function InternalResourceForm({
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (clientsData) {
|
if (clientsData) {
|
||||||
existingClients = (
|
existingClients = [
|
||||||
clientsData as { clientId: number; name: string }[]
|
...(clientsData as { clientId: number; name: string }[])
|
||||||
).map((c) => ({
|
];
|
||||||
id: c.clientId.toString(),
|
|
||||||
text: c.name
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,8 +598,7 @@ export function InternalResourceForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HorizontalTabs
|
<HorizontalTabs
|
||||||
clientSide={true}
|
clientSide
|
||||||
defaultTab={0}
|
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
title: t(
|
title: t(
|
||||||
@@ -610,7 +615,7 @@ export function InternalResourceForm({
|
|||||||
: [{ title: t("sshAccess"), href: "#" }])
|
: [{ title: t("sshAccess"), href: "#" }])
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<div className="space-y-4 mt-4">
|
<div className="space-y-4 mt-4 p-1">
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<label className="font-medium block">
|
<label className="font-medium block">
|
||||||
@@ -981,7 +986,7 @@ export function InternalResourceForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 mt-4">
|
<div className="space-y-4 mt-4 p-1">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<label className="font-medium block">
|
<label className="font-medium block">
|
||||||
{t("editInternalResourceDialogAccessControl")}
|
{t("editInternalResourceDialogAccessControl")}
|
||||||
@@ -1101,48 +1106,73 @@ export function InternalResourceForm({
|
|||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("machineClients")}
|
{t("machineClients")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<Button
|
||||||
{...field}
|
variant="outline"
|
||||||
activeTagIndex={
|
role="combobox"
|
||||||
activeClientsTagIndex
|
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
|
||||||
}
|
}
|
||||||
setActiveTagIndex={
|
className={cn(
|
||||||
setActiveClientsTagIndex
|
"bg-muted-foreground/20 font-normal text-foreground rounded-sm",
|
||||||
|
"py-1 px-1.5 text-xs"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
client.name
|
||||||
}
|
}
|
||||||
placeholder={
|
</span>
|
||||||
t(
|
)
|
||||||
|
)}
|
||||||
|
<span className="pl-1">
|
||||||
|
{t(
|
||||||
"accessClientSelect"
|
"accessClientSelect"
|
||||||
) ||
|
)}
|
||||||
"Select machine clients"
|
</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 ??
|
||||||
|
[]
|
||||||
}
|
}
|
||||||
size="sm"
|
orgId={orgId}
|
||||||
tags={
|
onSelectMachines={(
|
||||||
form.getValues()
|
machines
|
||||||
.clients ?? []
|
) => {
|
||||||
}
|
|
||||||
setTags={(newClients) =>
|
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"clients",
|
"clients",
|
||||||
newClients as [
|
machines
|
||||||
Tag,
|
);
|
||||||
...Tag[]
|
}}
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
enableAutocomplete={
|
|
||||||
true
|
|
||||||
}
|
|
||||||
autocompleteOptions={
|
|
||||||
allClients
|
|
||||||
}
|
|
||||||
allowDuplicates={false}
|
|
||||||
restrictTagsToAutocompleteOptions={
|
|
||||||
true
|
|
||||||
}
|
|
||||||
sortTags={true}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -1154,7 +1184,7 @@ export function InternalResourceForm({
|
|||||||
|
|
||||||
{/* SSH Access tab */}
|
{/* SSH Access tab */}
|
||||||
{!disableEnterpriseFeatures && mode !== "cidr" && (
|
{!disableEnterpriseFeatures && mode !== "cidr" && (
|
||||||
<div className="space-y-4 mt-4">
|
<div className="space-y-4 mt-4 p-1">
|
||||||
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
|
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<label className="font-medium block">
|
<label className="font-medium block">
|
||||||
|
|||||||
@@ -540,7 +540,7 @@ export default function MachineClientsTable({
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
rows={machineClients}
|
rows={machineClients}
|
||||||
tableId="machine-clients"
|
tableId="machine-clients"
|
||||||
searchPlaceholder={t("resourcesSearch")}
|
searchPlaceholder={t("machinesSearch")}
|
||||||
onAdd={() =>
|
onAdd={() =>
|
||||||
startNavigation(() =>
|
startNavigation(() =>
|
||||||
router.push(`/${orgId}/settings/clients/machine/create`)
|
router.push(`/${orgId}/settings/clients/machine/create`)
|
||||||
|
|||||||
@@ -770,7 +770,7 @@ export default function UserDevicesTable({
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
rows={userClients || []}
|
rows={userClients || []}
|
||||||
tableId="user-clients"
|
tableId="user-clients"
|
||||||
searchPlaceholder={t("resourcesSearch")}
|
searchPlaceholder={t("userDevicesSearch")}
|
||||||
onRefresh={refreshData}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isRefreshing || isFiltering}
|
isRefreshing={isRefreshing || isFiltering}
|
||||||
enableColumnVisibility
|
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,
|
CommandItem,
|
||||||
CommandList
|
CommandList
|
||||||
} from "./ui/command";
|
} from "./ui/command";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { CheckIcon } from "lucide-react";
|
import { CheckIcon } from "lucide-react";
|
||||||
import { cn } from "@app/lib/cn";
|
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 (
|
return (
|
||||||
<Command shouldFilter={false}>
|
<Command shouldFilter={false}>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
@@ -54,7 +69,7 @@ export function ResourceSelector({
|
|||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>{t("resourcesNotFound")}</CommandEmpty>
|
<CommandEmpty>{t("resourcesNotFound")}</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{resources.map((r) => (
|
{resourcesShown.map((r) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={`${r.name}:${r.resourceId}`}
|
value={`${r.name}:${r.resourceId}`}
|
||||||
key={r.resourceId}
|
key={r.resourceId}
|
||||||
|
|||||||
@@ -92,14 +92,26 @@ export const productUpdatesQueries = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const orgQueries = {
|
export const orgQueries = {
|
||||||
clients: ({ orgId }: { orgId: string }) =>
|
machineClients: ({
|
||||||
|
orgId,
|
||||||
|
query,
|
||||||
|
perPage = 10_000
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
query?: string;
|
||||||
|
perPage?: number;
|
||||||
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["ORG", orgId, "CLIENTS"] as const,
|
queryKey: ["ORG", orgId, "CLIENTS"] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
const sp = new URLSearchParams({
|
const sp = new URLSearchParams({
|
||||||
pageSize: "10000"
|
pageSize: perPage.toString()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (query?.trim()) {
|
||||||
|
sp.set("query", query);
|
||||||
|
}
|
||||||
|
|
||||||
const res = await meta!.api.get<
|
const res = await meta!.api.get<
|
||||||
AxiosResponse<ListClientsResponse>
|
AxiosResponse<ListClientsResponse>
|
||||||
>(`/org/${orgId}/clients?${sp.toString()}`, { signal });
|
>(`/org/${orgId}/clients?${sp.toString()}`, { signal });
|
||||||
|
|||||||
Reference in New Issue
Block a user