Merge pull request #2416 from akhilmhdh/ui/combobox

UI/combobox
This commit is contained in:
Maidul Islam
2024-09-12 18:50:43 -04:00
committed by GitHub
5 changed files with 179 additions and 47 deletions
+2 -1
View File
@@ -458,7 +458,6 @@ export const orgServiceFactory = ({
const appCfg = getConfig(); const appCfg = getConfig();
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member);
const org = await orgDAL.findOrgById(orgId); const org = await orgDAL.findOrgById(orgId);
@@ -583,6 +582,8 @@ export const orgServiceFactory = ({
// if there exist no org membership we set is as given by the request // if there exist no org membership we set is as given by the request
if (!inviteeMembership) { if (!inviteeMembership) {
// as its used by project invite also
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member);
let roleId; let roleId;
const orgRole = isCustomOrgRole ? OrgMembershipRole.Custom : organizationRoleSlug; const orgRole = isCustomOrgRole ? OrgMembershipRole.Custom : organizationRoleSlug;
if (isCustomOrgRole) { if (isCustomOrgRole) {
@@ -0,0 +1,83 @@
import { useState } from "react";
import { faCaretDown, faCheck } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Combobox, Transition } from "@headlessui/react";
import { ByComparator } from "@headlessui/react/dist/types";
import { twMerge } from "tailwind-merge";
type ComboBoxProps<T extends object> = {
value?: T;
className?: string;
items: {
value: T;
key: string;
label: string;
}[];
by: ByComparator<T>;
defaultValue?: T;
displayValue: (value: T) => string;
onSelectChange: (value: T) => void;
onFilter: (value: { value: T }, filterQuery: string) => boolean;
};
// TODO(akhilmhdh): This is a very temporary one due to limitation of present situation
// don't mind the api for now will be switched to aria later
export const ComboBox = <T extends object>({
onSelectChange,
onFilter,
displayValue,
by,
items,
...props
}: ComboBoxProps<T>) => {
const [query, setQuery] = useState("");
const filteredResult =
query === "" ? items.slice(0, 20) : items.filter((el) => onFilter(el, query)).slice(0, 20);
return (
<Combobox by={by} {...props} onChange={onSelectChange}>
<div className="relative">
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
displayValue={displayValue}
className=" inline-flex w-full items-center justify-between rounded-md bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none focus:bg-mineshaft-700/80 data-[placeholder]:text-mineshaft-200"
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
<FontAwesomeIcon icon={faCaretDown} size="sm" aria-hidden="true" />
</Combobox.Button>
<Transition
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
afterLeave={() => setQuery("")}
>
<Combobox.Options className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-mineshaft-900 py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm">
{filteredResult.map(({ value, key, label }) => (
<Combobox.Option
key={key}
value={value}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 transition-all hover:bg-mineshaft-500 ${
active ? "text-primary" : "text-white"
}`
}
>
{({ selected }) => (
<>
{label}
{selected ? (
<div className={twMerge("absolute top-2 left-3 text-primary")}>
<FontAwesomeIcon icon={faCheck} />
</div>
) : null}
</>
)}
</Combobox.Option>
))}
</Combobox.Options>
</Transition>
</div>
</Combobox>
);
};
@@ -0,0 +1 @@
export { ComboBox } from "./ComboBox";
+16 -2
View File
@@ -11,13 +11,24 @@ export type ModalContentProps = DialogPrimitive.DialogContentProps & {
title?: ReactNode; title?: ReactNode;
subTitle?: ReactNode; subTitle?: ReactNode;
footerContent?: ReactNode; footerContent?: ReactNode;
bodyClassName?: string;
onClose?: () => void; onClose?: () => void;
overlayClassName?: string; overlayClassName?: string;
}; };
export const ModalContent = forwardRef<HTMLDivElement, ModalContentProps>( export const ModalContent = forwardRef<HTMLDivElement, ModalContentProps>(
( (
{ children, title, subTitle, className, overlayClassName, footerContent, onClose, ...props }, {
children,
title,
subTitle,
className,
overlayClassName,
footerContent,
bodyClassName,
onClose,
...props
},
forwardedRef forwardedRef
) => ( ) => (
<DialogPrimitive.Portal> <DialogPrimitive.Portal>
@@ -35,7 +46,10 @@ export const ModalContent = forwardRef<HTMLDivElement, ModalContentProps>(
style={{ maxHeight: "90%" }} style={{ maxHeight: "90%" }}
> >
{title && <CardTitle subTitle={subTitle}>{title}</CardTitle>} {title && <CardTitle subTitle={subTitle}>{title}</CardTitle>}
<CardBody className="overflow-y-auto overflow-x-hidden" style={{ maxHeight: "90%" }}> <CardBody
className={twMerge("overflow-y-auto overflow-x-hidden", bodyClassName)}
style={{ maxHeight: "90%" }}
>
{children} {children}
</CardBody> </CardBody>
{footerContent && <CardFooter>{footerContent}</CardFooter>} {footerContent && <CardFooter>{footerContent}</CardFooter>}
@@ -1,19 +1,12 @@
import { useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import Link from "next/link"; import Link from "next/link";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup"; import * as yup from "yup";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { import { Button, FormControl, Modal, ModalClose, ModalContent } from "@app/components/v2";
Button, import { ComboBox } from "@app/components/v2/ComboBox";
FormControl,
Modal,
ModalClose,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context"; import { useOrganization, useWorkspace } from "@app/context";
import { import {
useAddIdentityToWorkspace, useAddIdentityToWorkspace,
@@ -25,8 +18,14 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = yup const schema = yup
.object({ .object({
identityId: yup.string().required("Identity id is required"), identity: yup.object({
role: yup.string() id: yup.string().required("Identity id is required"),
name: yup.string().required("Identity name is required")
}),
role: yup.object({
slug: yup.string().required("role slug is required"),
name: yup.string().required("role name is required")
})
}) })
.required(); .required();
@@ -56,7 +55,11 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
}); });
const identityMemberships = identityMembershipsData?.identityMemberships; const identityMemberships = identityMembershipsData?.identityMemberships;
const { data: roles } = useGetProjectRoles(projectSlug); const {
data: roles,
isLoading: isRolesLoading,
isFetched: isRolesFetched
} = useGetProjectRoles(projectSlug);
const { mutateAsync: addIdentityToWorkspaceMutateAsync } = useAddIdentityToWorkspace(); const { mutateAsync: addIdentityToWorkspaceMutateAsync } = useAddIdentityToWorkspace();
@@ -74,17 +77,24 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
control, control,
handleSubmit, handleSubmit,
reset, reset,
setValue,
formState: { isSubmitting } formState: { isSubmitting }
} = useForm<FormData>({ } = useForm<FormData>({
resolver: yupResolver(schema) resolver: yupResolver(schema)
}); });
const onFormSubmit = async ({ identityId, role }: FormData) => { useEffect(() => {
if (!isRolesFetched || !roles) return;
setValue("role", { name: roles[0]?.name, slug: roles[0]?.slug });
}, [isRolesFetched, roles]);
const onFormSubmit = async ({ identity, role }: FormData) => {
try { try {
await addIdentityToWorkspaceMutateAsync({ await addIdentityToWorkspaceMutateAsync({
workspaceId, workspaceId,
identityId, identityId: identity.id,
role: role || undefined role: role.slug || undefined
}); });
createNotification({ createNotification({
@@ -92,7 +102,17 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
type: "success" type: "success"
}); });
reset(); const nextAvailableMembership = filteredIdentityMembershipOrgs.filter(
(membership) => membership.identity.id !== identity.id
)[0];
// prevents combobox from displaying previously added identity
reset({
identity: {
name: nextAvailableMembership?.identity.name,
id: nextAvailableMembership?.identity.id
}
});
handlePopUpToggle("identity", false); handlePopUpToggle("identity", false);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -114,34 +134,41 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
reset(); reset();
}} }}
> >
<ModalContent title="Add Identity to Project"> <ModalContent title="Add Identity to Project" bodyClassName="overflow-visible">
{filteredIdentityMembershipOrgs.length ? ( {filteredIdentityMembershipOrgs.length ? (
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={handleSubmit(onFormSubmit)}>
<Controller <Controller
control={control} control={control}
name="identityId" name="identity"
defaultValue={filteredIdentityMembershipOrgs?.[0]?.id} defaultValue={{
id: filteredIdentityMembershipOrgs?.[0]?.identity?.id,
name: filteredIdentityMembershipOrgs?.[0]?.identity?.name
}}
render={({ field: { onChange, ...field }, fieldState: { error } }) => ( render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Identity" errorText={error?.message} isError={Boolean(error)}> <FormControl label="Identity" errorText={error?.message} isError={Boolean(error)}>
<Select <ComboBox
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full" className="w-full"
> by="id"
{filteredIdentityMembershipOrgs.map(({ identity }) => ( value={{ id: field.value.id, name: field.value.name }}
<SelectItem value={identity.id} key={`org-identity-${identity.id}`}> defaultValue={{ id: field.value.id, name: field.value.name }}
{identity.name} onSelectChange={(value) => onChange({ id: value.id, name: value.name })}
</SelectItem> displayValue={(el) => el.name}
))} onFilter={({ value }, filterQuery) =>
</Select> value.name.toLowerCase().includes(filterQuery.toLowerCase())
}
items={filteredIdentityMembershipOrgs.map(({ identity }) => ({
key: identity.id,
value: { id: identity.id, name: identity.name },
label: identity.name
}))}
/>
</FormControl> </FormControl>
)} )}
/> />
<Controller <Controller
control={control} control={control}
name="role" name="role"
defaultValue="" defaultValue={{ name: "", slug: "" }}
render={({ field: { onChange, ...field }, fieldState: { error } }) => ( render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl <FormControl
label="Role" label="Role"
@@ -149,18 +176,22 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
isError={Boolean(error)} isError={Boolean(error)}
className="mt-4" className="mt-4"
> >
<Select <ComboBox
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full" className="w-full"
> by="slug"
{(roles || []).map(({ name, slug }) => ( value={{ slug: field.value.slug, name: field.value.name }}
<SelectItem value={slug} key={`st-role-${slug}`}> defaultValue={{ slug: field.value.slug, name: field.value.name }}
{name} onSelectChange={(value) => onChange({ slug: value.slug, name: value.name })}
</SelectItem> displayValue={(el) => el.name}
))} onFilter={({ value }, filterQuery) =>
</Select> value.name.toLowerCase().includes(filterQuery.toLowerCase())
}
items={(roles || []).map(({ slug, name }) => ({
key: slug,
value: { slug, name },
label: name
}))}
/>
</FormControl> </FormControl>
)} )}
/> />
@@ -187,7 +218,9 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
All identities in your organization have already been added to this project. All identities in your organization have already been added to this project.
</div> </div>
<Link href={`/org/${currentWorkspace?.orgId}/members`}> <Link href={`/org/${currentWorkspace?.orgId}/members`}>
<Button variant="outline_bg">Create a new identity</Button> <Button isDisabled={isRolesLoading} isLoading={isRolesLoading} variant="outline_bg">
Create a new identity
</Button>
</Link> </Link>
</div> </div>
)} )}