♻️ make machine selector a multi-combobox

This commit is contained in:
Fred KISSIE
2026-03-20 03:59:10 +01:00
parent e15703164d
commit ce58e71c44
8 changed files with 231 additions and 120 deletions

View File

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

View File

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

View File

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

View File

@@ -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`)

View File

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

View 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>
);
}

View File

@@ -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}

View File

@@ -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 });