♻️ 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", "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",

View File

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

View File

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

View File

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

View File

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

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

View File

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