🚧 users selector

This commit is contained in:
Fred KISSIE
2026-04-25 06:18:13 +02:00
parent 91ce8bea4b
commit 27b2ec309d
8 changed files with 114 additions and 15 deletions

View File

@@ -60,6 +60,7 @@ import { MachinesSelector } from "./machines-selector";
import DomainPicker from "@app/components/DomainPicker"; import DomainPicker from "@app/components/DomainPicker";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import CertificateStatus from "@app/components/CertificateStatus"; import CertificateStatus from "@app/components/CertificateStatus";
import { UsersSelector } from "./users-selector";
// --- Helpers (shared) --- // --- Helpers (shared) ---
@@ -1522,7 +1523,7 @@ export function InternalResourceForm({
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel>{t("users")}</FormLabel> <FormLabel>{t("users")}</FormLabel>
<FormControl> {/* <FormControl>
<TagInput <TagInput
{...field} {...field}
activeTagIndex={ activeTagIndex={
@@ -1558,7 +1559,23 @@ export function InternalResourceForm({
} }
sortTags={true} sortTags={true}
/> />
</FormControl> </FormControl> */}
<UsersSelector
{...field}
selectedUsers={
field.value ?? []
}
orgId={orgId}
onSelectUsers={(newUsers) => {
form.setValue(
"users",
newUsers as [
Tag,
...Tag[]
]
);
}}
/>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View File

@@ -5,7 +5,7 @@ import { useMemo, useState } from "react";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { MultiSelectInput } from "./multi-select/multi-select-input"; import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
export type SelectedMachine = Pick< export type SelectedMachine = Pick<
ListClientsResponse["clients"][number], ListClientsResponse["clients"][number],
@@ -46,11 +46,11 @@ export function MachinesSelector({
} }
} }
} }
return allMachines.slice(0, perPage); return allMachines;
}, [machines, selectedMachines, debouncedValue]); }, [machines, selectedMachines, debouncedValue]);
return ( return (
<MultiSelectInput <MultiSelectTagInput
buttonText={t("accessClientSelect")} buttonText={t("accessClientSelect")}
searchPlaceholder={t("search")} searchPlaceholder={t("search")}
emptyPlaceholder={t("machineNotFound")} emptyPlaceholder={t("machineNotFound")}

View File

@@ -23,7 +23,7 @@ export type MultiSelectTagsProps<T extends TagValue> = {
ref?: Ref<HTMLButtonElement>; ref?: Ref<HTMLButtonElement>;
}; };
export function MultiSelectTags<T extends TagValue>({ export function MultiSelectContent<T extends TagValue>({
emptyPlaceholder, emptyPlaceholder,
searchPlaceholder, searchPlaceholder,
searchQuery, searchQuery,
@@ -40,7 +40,8 @@ export function MultiSelectTags<T extends TagValue>({
value={searchQuery} value={searchQuery}
onValueChange={onSearch} onValueChange={onSearch}
/> />
<CommandList> {/* FIXME: why isn't this list scrolling ????? */}
<CommandList className="scroll-py-0 max-h-20">
<CommandEmpty>{emptyPlaceholder}</CommandEmpty> <CommandEmpty>{emptyPlaceholder}</CommandEmpty>
<CommandGroup> <CommandGroup>
{options.map((option) => ( {options.map((option) => (

View File

@@ -9,8 +9,8 @@ import { ChevronDownIcon, XIcon } from "lucide-react";
import { import {
type TagValue, type TagValue,
type MultiSelectTagsProps, type MultiSelectTagsProps,
MultiSelectTags MultiSelectContent
} from "./multi-select-tags"; } from "./multi-select-content";
import { useState } from "react"; import { useState } from "react";
export interface MultiSelectInputProps< export interface MultiSelectInputProps<
@@ -19,7 +19,7 @@ export interface MultiSelectInputProps<
buttonText?: string; buttonText?: string;
} }
export function MultiSelectInput<T extends TagValue>({ export function MultiSelectTagInput<T extends TagValue>({
buttonText, buttonText,
...props ...props
}: MultiSelectInputProps<T>) { }: MultiSelectInputProps<T>) {
@@ -83,7 +83,7 @@ export function MultiSelectInput<T extends TagValue>({
</div> </div>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0"> <PopoverContent className="p-0">
<MultiSelectTags {...props} /> <MultiSelectContent {...props} />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );

View File

@@ -0,0 +1 @@
// TODO

View File

@@ -87,7 +87,7 @@ function CommandList({
<CommandPrimitive.List <CommandPrimitive.List
data-slot="command-list" data-slot="command-list"
className={cn( className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", "max-h-[300px] scroll-py-1 overflow-x-clip overflow-y-auto",
className className
)} )}
{...props} {...props}

View File

@@ -0,0 +1,64 @@
import { orgQueries } from "@app/lib/queries";
import type { ListUsersResponse } from "@server/routers/user";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { useDebounce } from "use-debounce";
import { useTranslations } from "next-intl";
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
export type SelectedUser = { id: string; text: string };
export type UsersSelectorProps = {
orgId: string;
selectedUsers?: SelectedUser[];
onSelectUsers: (users: SelectedUser[]) => void;
};
export function UsersSelector({
orgId,
selectedUsers = [],
onSelectUsers
}: UsersSelectorProps) {
const t = useTranslations();
const [userSearchQuery, setUserSearchQuery] = useState("");
const [debouncedValue] = useDebounce(userSearchQuery, 150);
// TODO: switch back to 7 items
const perPage = 1;
const { data: users = [] } = useQuery(
orgQueries.users({ orgId, perPage, query: debouncedValue })
);
// always include the selected users in the list (if the user isn't searching)
const usersShown = useMemo(() => {
const allUsers: Array<SelectedUser> = users.map((u) => ({
id: u.id,
text: getUserDisplayName(u)
}));
if (debouncedValue.trim().length === 0) {
for (const user of selectedUsers) {
if (!allUsers.find((u) => u.id === user.id)) {
allUsers.unshift(user);
}
}
}
return allUsers;
}, [users, selectedUsers, debouncedValue]);
return (
<MultiSelectTagInput
buttonText={t("accessUserSelect")}
searchPlaceholder={t("search")}
emptyPlaceholder={t("userNotFoundWithUsername")}
searchQuery={userSearchQuery}
onSearch={setUserSearchQuery}
options={usersShown}
value={selectedUsers}
onChange={onSelectUsers}
/>
);
}

View File

@@ -125,13 +125,29 @@ export const orgQueries = {
return res.data.data.clients; return res.data.data.clients;
} }
}), }),
users: ({ orgId }: { orgId: string }) => users: ({
orgId,
query,
perPage = 10_000
}: {
orgId: string;
query?: string;
perPage?: number;
}) =>
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "USERS"] as const, queryKey: ["ORG", orgId, "USERS", { query, perPage }] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: perPage.toString()
});
if (query?.trim()) {
sp.set("query", query);
}
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListUsersResponse> AxiosResponse<ListUsersResponse>
>(`/org/${orgId}/users`, { signal }); >(`/org/${orgId}/users?${sp.toString()}`, { signal });
return res.data.data.users; return res.data.data.users;
} }