mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-12 23:20:56 +00:00
♻️ replace roles & user selectors in machines & create user
This commit is contained in:
@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
|
||||
return (
|
||||
<CredenzaContent
|
||||
className={cn(
|
||||
"flex min-h-0 max-h-[100dvh] flex-col overflow-hidden md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
|
||||
"flex min-h-0 max-h-[100dvh] flex-col overflow-y-auto md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -61,6 +61,7 @@ import DomainPicker from "@app/components/DomainPicker";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import CertificateStatus from "@app/components/CertificateStatus";
|
||||
import { UsersSelector } from "./users-selector";
|
||||
import { RolesSelector } from "./roles-selector";
|
||||
|
||||
// --- Helpers (shared) ---
|
||||
|
||||
@@ -1477,40 +1478,22 @@ export function InternalResourceForm({
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("roles")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeRolesTagIndex
|
||||
<RolesSelector
|
||||
selectedRoles={
|
||||
field.value ?? []
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveRolesTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"accessRoleSelect2"
|
||||
)}
|
||||
size="sm"
|
||||
tags={
|
||||
form.getValues()
|
||||
.roles ?? []
|
||||
}
|
||||
setTags={(newRoles) =>
|
||||
orgId={orgId}
|
||||
onSelectRoles={(
|
||||
newUsers
|
||||
) => {
|
||||
form.setValue(
|
||||
"roles",
|
||||
newRoles as [
|
||||
newUsers as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
)
|
||||
}
|
||||
enableAutocomplete
|
||||
autocompleteOptions={
|
||||
allRoles
|
||||
}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -1523,45 +1506,7 @@ export function InternalResourceForm({
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("users")}</FormLabel>
|
||||
{/* <FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeUsersTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveUsersTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"accessUserSelect"
|
||||
)}
|
||||
tags={
|
||||
form.getValues()
|
||||
.users ?? []
|
||||
}
|
||||
size="sm"
|
||||
setTags={(newUsers) =>
|
||||
form.setValue(
|
||||
"users",
|
||||
newUsers as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
)
|
||||
}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={
|
||||
allUsers
|
||||
}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl> */}
|
||||
<UsersSelector
|
||||
{...field}
|
||||
selectedUsers={
|
||||
field.value ?? []
|
||||
}
|
||||
@@ -1590,7 +1535,6 @@ export function InternalResourceForm({
|
||||
{t("machineClients")}
|
||||
</FormLabel>
|
||||
<MachinesSelector
|
||||
{...field}
|
||||
selectedMachines={
|
||||
field.value ?? []
|
||||
}
|
||||
@@ -1604,73 +1548,6 @@ export function InternalResourceForm({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{/* <Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between w-full",
|
||||
"text-muted-foreground pl-1.5 cursor-text"
|
||||
)}
|
||||
>
|
||||
<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 font-normal">
|
||||
{t(
|
||||
"accessClientSelect"
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<MachinesSelector
|
||||
selectedMachines={
|
||||
field.value ??
|
||||
[]
|
||||
}
|
||||
orgId={orgId}
|
||||
onSelectMachines={(
|
||||
machines
|
||||
) => {
|
||||
form.setValue(
|
||||
"clients",
|
||||
machines
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover> */}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -8,51 +8,42 @@ import {
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
|
||||
|
||||
export type RoleTag = {
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
|
||||
import { RolesSelector, type SelectedRole } from "./roles-selector";
|
||||
|
||||
type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = {
|
||||
form: Pick<UseFormReturn<TFieldValues>, "control" | "getValues" | "setValue">;
|
||||
form: Pick<
|
||||
UseFormReturn<TFieldValues>,
|
||||
"control" | "getValues" | "setValue"
|
||||
>;
|
||||
orgId: string;
|
||||
/** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */
|
||||
name?: Path<TFieldValues>;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
allRoleOptions: Tag[];
|
||||
label?: string;
|
||||
supportsMultipleRolesPerUser: boolean;
|
||||
showMultiRolePaywallMessage: boolean;
|
||||
paywallMessage: string;
|
||||
loading?: boolean;
|
||||
activeTagIndex: number | null;
|
||||
setActiveTagIndex: Dispatch<SetStateAction<number | null>>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
||||
form,
|
||||
name = "roles" as Path<TFieldValues>,
|
||||
label,
|
||||
placeholder,
|
||||
allRoleOptions,
|
||||
orgId,
|
||||
supportsMultipleRolesPerUser,
|
||||
showMultiRolePaywallMessage,
|
||||
paywallMessage,
|
||||
loading = false,
|
||||
activeTagIndex,
|
||||
setActiveTagIndex
|
||||
disabled
|
||||
}: OrgRolesTagFieldProps<TFieldValues>) {
|
||||
const t = useTranslations();
|
||||
|
||||
function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) {
|
||||
const prev = form.getValues(name) as Tag[];
|
||||
const nextValue =
|
||||
typeof updater === "function" ? updater(prev) : updater;
|
||||
function setRoleTags(nextValue: SelectedRole[]) {
|
||||
const prev = form.getValues(name) as SelectedRole[];
|
||||
const next = supportsMultipleRolesPerUser
|
||||
? nextValue
|
||||
: nextValue.length > 1
|
||||
@@ -88,22 +79,13 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormLabel>{label ?? t("roles")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeTagIndex}
|
||||
setActiveTagIndex={setActiveTagIndex}
|
||||
placeholder={placeholder}
|
||||
size="sm"
|
||||
tags={field.value}
|
||||
setTags={setRoleTags}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={allRoleOptions}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={true}
|
||||
sortTags={true}
|
||||
disabled={loading}
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={field.value ?? []}
|
||||
onSelectRoles={setRoleTags}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
{showMultiRolePaywallMessage && (
|
||||
|
||||
@@ -21,6 +21,7 @@ export type MultiSelectTagsProps<T extends TagValue> = {
|
||||
onChange: (newValue: Array<T>) => void;
|
||||
onSearch: (query: string) => void;
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function MultiSelectContent<T extends TagValue>({
|
||||
@@ -40,8 +41,7 @@ export function MultiSelectContent<T extends TagValue>({
|
||||
value={searchQuery}
|
||||
onValueChange={onSearch}
|
||||
/>
|
||||
{/* FIXME: why isn't this list scrolling ????? */}
|
||||
<CommandList className="scroll-py-0 max-h-20">
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyPlaceholder}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { buttonVariants } from "@app/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { Button, buttonVariants } from "@app/components/ui/button";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import {
|
||||
type TagValue,
|
||||
type MultiSelectTagsProps,
|
||||
type TagValue,
|
||||
MultiSelectContent
|
||||
} from "./multi-select-content";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface MultiSelectInputProps<
|
||||
T extends TagValue
|
||||
@@ -36,7 +35,8 @@ export function MultiSelectTagInput<T extends TagValue>({
|
||||
}),
|
||||
"justify-between w-full inline-flex",
|
||||
"text-muted-foreground pl-1.5 cursor-text",
|
||||
"hover:bg-transparent hover:text-muted-foreground"
|
||||
"hover:bg-transparent hover:text-muted-foreground",
|
||||
props.disabled && "pointer-events-none opacity-50"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@@ -57,6 +57,7 @@ export function MultiSelectTagInput<T extends TagValue>({
|
||||
{option.text}
|
||||
<button
|
||||
className="p-0.5 flex-none cursor-pointer"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
let newValues = [];
|
||||
|
||||
@@ -1 +1,64 @@
|
||||
// TODO
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
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 SelectedRole = { id: string; text: string };
|
||||
|
||||
export type RolesSelectorProps = {
|
||||
orgId: string;
|
||||
selectedRoles?: SelectedRole[];
|
||||
onSelectRoles: (roles: SelectedRole[]) => void;
|
||||
disabled?: boolean
|
||||
};
|
||||
|
||||
export function RolesSelector({
|
||||
orgId,
|
||||
selectedRoles = [],
|
||||
onSelectRoles,
|
||||
disabled
|
||||
}: RolesSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [roleSearchQuery, setRoleSearchQuery] = useState("");
|
||||
|
||||
const [debouncedValue] = useDebounce(roleSearchQuery, 150);
|
||||
|
||||
const perPage = 7;
|
||||
|
||||
const { data: roles = [] } = useQuery(
|
||||
orgQueries.roles({ orgId, perPage, query: debouncedValue })
|
||||
);
|
||||
|
||||
// always include the selected roles in the list (if the user isn't searching)
|
||||
const rolesShown = useMemo(() => {
|
||||
const allRoles: Array<SelectedRole> = roles.map((r) => ({
|
||||
id: r.roleId.toString(),
|
||||
text: r.name
|
||||
}));
|
||||
if (debouncedValue.trim().length === 0) {
|
||||
for (const role of selectedRoles) {
|
||||
if (!allRoles.find((r) => r.id === role.id)) {
|
||||
allRoles.unshift(role);
|
||||
}
|
||||
}
|
||||
}
|
||||
return allRoles;
|
||||
}, [roles, selectedRoles, debouncedValue]);
|
||||
|
||||
return (
|
||||
<MultiSelectTagInput
|
||||
buttonText={t("selectRole")}
|
||||
searchPlaceholder={t("search")}
|
||||
emptyPlaceholder={t("roles")}
|
||||
searchQuery={roleSearchQuery}
|
||||
onSearch={setRoleSearchQuery}
|
||||
options={rolesShown}
|
||||
value={selectedRoles}
|
||||
onChange={onSelectRoles}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ function CommandGroup({
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-y-auto p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -26,8 +26,7 @@ export function UsersSelector({
|
||||
|
||||
const [debouncedValue] = useDebounce(userSearchQuery, 150);
|
||||
|
||||
// TODO: switch back to 7 items
|
||||
const perPage = 1;
|
||||
const perPage = 7;
|
||||
|
||||
const { data: users = [] } = useQuery(
|
||||
orgQueries.users({ orgId, perPage, query: debouncedValue })
|
||||
|
||||
Reference in New Issue
Block a user