Merge pull request #2565 from scott-ray-wilson/add-project-users-multi-select

Feature: Multi-Select Component and Improve Adding Users to Project
This commit is contained in:
Scott Wilson
2024-10-09 10:45:59 -07:00
committed by GitHub
7 changed files with 394 additions and 212 deletions
@@ -0,0 +1,104 @@
import Select, {
ClearIndicatorProps,
components,
DropdownIndicatorProps,
MultiValueRemoveProps,
OptionProps,
Props
} from "react-select";
import { faCheckCircle, faCircleXmark } from "@fortawesome/free-regular-svg-icons";
import { faChevronDown, faX } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
const DropdownIndicator = (props: DropdownIndicatorProps) => {
return (
<components.DropdownIndicator {...props}>
<FontAwesomeIcon icon={faChevronDown} size="xs" />
</components.DropdownIndicator>
);
};
const ClearIndicator = (props: ClearIndicatorProps) => {
return (
<components.ClearIndicator {...props}>
<FontAwesomeIcon icon={faCircleXmark} />
</components.ClearIndicator>
);
};
const MultiValueRemove = (props: MultiValueRemoveProps) => {
return (
<components.MultiValueRemove {...props}>
<FontAwesomeIcon icon={faX} size="xs" />
</components.MultiValueRemove>
);
};
const Option = ({ isSelected, children, ...props }: OptionProps) => {
return (
<components.Option isSelected={isSelected} {...props}>
{children}
{isSelected && (
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
)}
</components.Option>
);
};
export const MultiSelect = (props: Props) => (
<Select
isMulti
closeMenuOnSelect={false}
hideSelectedOptions={false}
unstyled
styles={{
input: (base) => ({
...base,
"input:focus": {
boxShadow: "none"
}
}),
multiValueLabel: (base) => ({
...base,
whiteSpace: "normal",
overflow: "visible"
}),
control: (base) => ({
...base,
transition: "none"
})
}}
components={{ DropdownIndicator, ClearIndicator, MultiValueRemove, Option }}
classNames={{
container: () => "w-full font-inter",
control: ({ isFocused }) =>
twMerge(
isFocused ? "border-primary-400/50" : "border-mineshaft-600 hover:border-gray-400",
"border w-full p-0.5 rounded-md font-inter bg-mineshaft-900 hover:cursor-pointer"
),
placeholder: () => "text-mineshaft-400 text-sm pl-1 py-0.5",
input: () => "pl-1 py-0.5",
valueContainer: () => "p-1 max-h-[14rem] !overflow-y-scroll gap-1",
singleValue: () => "leading-7 ml-1",
multiValue: () => "bg-mineshaft-600 rounded items-center py-0.5 px-2 gap-1.5",
multiValueLabel: () => "leading-6 text-sm",
multiValueRemove: () => "hover:text-red text-bunker-400",
indicatorsContainer: () => "p-1 gap-1",
clearIndicator: () => "p-1 hover:text-red text-bunker-400",
indicatorSeparator: () => "bg-bunker-400",
dropdownIndicator: () => "text-bunker-200 p-1",
menu: () =>
"mt-2 border text-sm text-mineshaft-200 bg-mineshaft-900 border-mineshaft-600 rounded-md",
groupHeading: () => "ml-3 mt-2 mb-1 text-mineshaft-400 text-sm",
option: ({ isFocused, isSelected }) =>
twMerge(
isFocused && "bg-mineshaft-700 active:bg-mineshaft-600",
isSelected && "text-mineshaft-400",
"hover:cursor-pointer text-xs px-3 py-2"
),
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md"
}}
{...props}
/>
);
@@ -0,0 +1 @@
export * from "./MultiSelect";
+1
View File
@@ -18,6 +18,7 @@ export * from "./IconButton";
export * from "./Input";
export * from "./Menu";
export * from "./Modal";
export * from "./MultiSelect";
export * from "./NoticeBanner";
export * from "./Pagination";
export * from "./Popoverv2";
@@ -17,7 +17,8 @@ import {
DropdownMenuTrigger,
FormControl,
Modal,
ModalContent
ModalContent,
MultiSelect
} from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import {
@@ -31,7 +32,7 @@ import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
const addMemberFormSchema = z.object({
orgMembershipIds: z.array(z.string().trim()).min(1),
orgMemberships: z.array(z.object({ label: z.string().trim(), value: z.string().trim() })).min(1),
projectRoleSlugs: z.array(z.string().trim().min(1)).min(1)
});
@@ -60,20 +61,20 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
handleSubmit,
reset,
watch,
formState: { isSubmitting }
formState: { isSubmitting, errors }
} = useForm<TAddMemberForm>({
resolver: zodResolver(addMemberFormSchema),
defaultValues: { orgMembershipIds: [], projectRoleSlugs: [ProjectMembershipRole.Member] }
defaultValues: { orgMemberships: [], projectRoleSlugs: [ProjectMembershipRole.Member] }
});
const { mutateAsync: addMembersToProject } = useAddUsersToOrg();
const onAddMember = async ({ orgMembershipIds, projectRoleSlugs }: TAddMemberForm) => {
const onAddMembers = async ({ orgMemberships, projectRoleSlugs }: TAddMemberForm) => {
if (!currentWorkspace) return;
if (!currentOrg?.id) return;
const selectedMembers = orgMembershipIds.map((orgMembershipId) =>
orgUsers?.find((orgUser) => orgUser.id === orgMembershipId)
const selectedMembers = orgMemberships.map((orgMembership) =>
orgUsers?.find((orgUser) => orgUser.id === orgMembership.value)
);
if (!selectedMembers) return;
@@ -118,10 +119,15 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
members?.forEach((member) => {
wsUserUsernames.set(member.user.username, true);
});
return (orgUsers || []).filter(({ user: u }) => !wsUserUsernames.has(u.username));
return (orgUsers || [])
.filter(({ user: u }) => !wsUserUsernames.has(u.username))
.map((member) => ({
value: member.id,
label: `${member.user.firstName} ${member.user.lastName}`
}));
}, [orgUsers, members]);
const selectedOrgMembershipIds = watch("orgMembershipIds");
const selectedOrgMemberships = watch("orgMemberships");
const selectedRoleSlugs = watch("projectRoleSlugs");
return (
@@ -130,175 +136,115 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
onOpenChange={(isOpen) => handlePopUpToggle("addMember", isOpen)}
>
<ModalContent
bodyClassName="overflow-visible"
title={t("section.members.add-dialog.add-member-to-project") as string}
subTitle={t("section.members.add-dialog.user-will-email")}
>
{filteredOrgUsers.length ? (
<form onSubmit={handleSubmit(onAddMember)}>
<div className="flex w-full items-center gap-2">
<div className="w-full">
<Controller
control={control}
name="orgMembershipIds"
render={({ field }) => (
<FormControl label="Invite users to project">
<DropdownMenu>
<DropdownMenuTrigger asChild>
{filteredOrgUsers && filteredOrgUsers.length > 0 ? (
<div className="inline-flex w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
{/* eslint-disable-next-line no-nested-ternary */}
{selectedOrgMembershipIds.length === 1
? filteredOrgUsers.find(
(orgUser) => orgUser.id === selectedOrgMembershipIds[0]
)?.user.username
: selectedOrgMembershipIds.length === 0
? "No users selected"
: `${selectedOrgMembershipIds.length} users selected`}
<FontAwesomeIcon icon={faChevronDown} className="text-xs" />
</div>
) : (
<div className="inline-flex w-full cursor-default items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
No users found
</div>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="thin-scrollbar z-[100] max-h-80"
>
{filteredOrgUsers && filteredOrgUsers.length > 0 ? (
filteredOrgUsers.map((member) => {
const isSelected = selectedOrgMembershipIds.includes(member.id);
<form onSubmit={handleSubmit(onAddMembers)}>
<div className="flex w-full flex-col items-start gap-2">
<Controller
control={control}
name="orgMemberships"
render={({ field }) => (
<FormControl
className="w-full"
isError={!!errors.orgMemberships?.length}
errorText={errors.orgMemberships?.[0]?.message}
label="Invite users to project"
>
<MultiSelect
className="w-full"
placeholder="Add one or more users..."
isMulti
name="members"
options={filteredOrgUsers}
value={field.value}
onChange={field.onChange}
/>
</FormControl>
)}
/>
return (
<DropdownMenuItem
onSelect={(event) =>
filteredOrgUsers.length > 1 && event.preventDefault()
}
onClick={() => {
if (selectedOrgMembershipIds.includes(String(member.id))) {
field.onChange(
selectedOrgMembershipIds.filter(
(membershipId: string) =>
membershipId !== String(member.id)
)
);
} else {
field.onChange([
...selectedOrgMembershipIds,
String(member.id)
]);
}
}}
key={`membership-id-${member.id}`}
icon={
isSelected ? (
<FontAwesomeIcon
icon={faCheckCircle}
className="pr-0.5 text-primary"
/>
) : (
<div className="pl-[1.01rem]" />
)
}
iconPos="left"
className="w-[28.4rem] text-sm"
>
{member.user.username}
</DropdownMenuItem>
);
})
) : (
<div />
)}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
</div>
<Controller
control={control}
name="projectRoleSlugs"
render={({ field }) => (
<FormControl
className="w-full"
label="Select roles"
tooltipText="Select the roles that you wish to assign to the users"
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{roles && roles.length > 0 ? (
<div className="inline-flex w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
{/* eslint-disable-next-line no-nested-ternary */}
{selectedRoleSlugs.length === 1
? roles.find((role) => role.slug === selectedRoleSlugs[0])?.name
: selectedRoleSlugs.length === 0
? "Select at least one role"
: `${selectedRoleSlugs.length} roles selected`}
<FontAwesomeIcon
icon={faChevronDown}
className={twMerge("ml-2 text-xs")}
/>
</div>
) : (
<div className="inline-flex w-full cursor-default items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
No roles found
</div>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="thin-scrollbar z-[100] max-h-80"
>
{roles && roles.length > 0 ? (
roles.map((role) => {
const isSelected = selectedRoleSlugs.includes(role.slug);
<div className="flex min-w-fit justify-end">
<Controller
control={control}
name="projectRoleSlugs"
render={({ field }) => (
<FormControl
label="Select roles"
tooltipText="Select the roles that you wish to assign to the users"
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{roles && roles.length > 0 ? (
<div className="inline-flex w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
{/* eslint-disable-next-line no-nested-ternary */}
{selectedRoleSlugs.length === 1
? roles.find((role) => role.slug === selectedRoleSlugs[0])?.name
: selectedRoleSlugs.length === 0
? "Select at least one role"
: `${selectedRoleSlugs.length} roles selected`}
<FontAwesomeIcon
icon={faChevronDown}
className={twMerge("ml-2 text-xs")}
/>
</div>
) : (
<div className="inline-flex w-full cursor-default items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
No roles found
</div>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="thin-scrollbar z-[100] max-h-80"
>
{roles && roles.length > 0 ? (
roles.map((role) => {
const isSelected = selectedRoleSlugs.includes(role.slug);
return (
<DropdownMenuItem
onSelect={(event) => roles.length > 1 && event.preventDefault()}
onClick={() => {
if (selectedRoleSlugs.includes(String(role.slug))) {
field.onChange(
selectedRoleSlugs.filter(
(roleSlug: string) => roleSlug !== String(role.slug)
)
);
} else {
field.onChange([...selectedRoleSlugs, role.slug]);
}
}}
key={`role-slug-${role.slug}`}
icon={
isSelected ? (
<FontAwesomeIcon
icon={faCheckCircle}
className="pr-0.5 text-primary"
/>
) : (
<div className="pl-[1.01rem]" />
)
return (
<DropdownMenuItem
onSelect={(event) => roles.length > 1 && event.preventDefault()}
onClick={() => {
if (selectedRoleSlugs.includes(String(role.slug))) {
field.onChange(
selectedRoleSlugs.filter(
(roleSlug: string) => roleSlug !== String(role.slug)
)
);
} else {
field.onChange([...selectedRoleSlugs, role.slug]);
}
iconPos="left"
className="w-[28.4rem] text-sm"
>
{role.name}
</DropdownMenuItem>
);
})
) : (
<div />
)}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
</div>
}}
key={`role-slug-${role.slug}`}
icon={
isSelected ? (
<FontAwesomeIcon
icon={faCheckCircle}
className="pr-0.5 text-primary"
/>
) : (
<div className="pl-[1.01rem]" />
)
}
iconPos="left"
className="w-[28.4rem] text-sm"
>
{role.name}
</DropdownMenuItem>
);
})
) : (
<div />
)}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
</div>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
@@ -307,7 +253,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
isLoading={isSubmitting}
isDisabled={
isSubmitting ||
selectedOrgMembershipIds.length === 0 ||
selectedOrgMemberships.length === 0 ||
selectedRoleSlugs.length === 0
}
>