diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 5b4efb992..c0a640112 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -458,7 +458,6 @@ export const orgServiceFactory = ({ const appCfg = getConfig(); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); 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 (!inviteeMembership) { + // as its used by project invite also + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); let roleId; const orgRole = isCustomOrgRole ? OrgMembershipRole.Custom : organizationRoleSlug; if (isCustomOrgRole) { diff --git a/frontend/src/components/v2/ComboBox/ComboBox.tsx b/frontend/src/components/v2/ComboBox/ComboBox.tsx new file mode 100644 index 000000000..c590110c3 --- /dev/null +++ b/frontend/src/components/v2/ComboBox/ComboBox.tsx @@ -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 = { + value?: T; + className?: string; + items: { + value: T; + key: string; + label: string; + }[]; + by: ByComparator; + 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 = ({ + onSelectChange, + onFilter, + displayValue, + by, + items, + ...props +}: ComboBoxProps) => { + const [query, setQuery] = useState(""); + + const filteredResult = + query === "" ? items.slice(0, 20) : items.filter((el) => onFilter(el, query)).slice(0, 20); + + return ( + +
+ 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" + /> + + + setQuery("")} + > + + {filteredResult.map(({ value, key, label }) => ( + + `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 ? ( +
+ +
+ ) : null} + + )} +
+ ))} +
+
+
+
+ ); +}; diff --git a/frontend/src/components/v2/ComboBox/index.tsx b/frontend/src/components/v2/ComboBox/index.tsx new file mode 100644 index 000000000..912296adf --- /dev/null +++ b/frontend/src/components/v2/ComboBox/index.tsx @@ -0,0 +1 @@ +export { ComboBox } from "./ComboBox"; diff --git a/frontend/src/components/v2/Modal/Modal.tsx b/frontend/src/components/v2/Modal/Modal.tsx index 90abfecbd..a741d6d02 100644 --- a/frontend/src/components/v2/Modal/Modal.tsx +++ b/frontend/src/components/v2/Modal/Modal.tsx @@ -11,13 +11,24 @@ export type ModalContentProps = DialogPrimitive.DialogContentProps & { title?: ReactNode; subTitle?: ReactNode; footerContent?: ReactNode; + bodyClassName?: string; onClose?: () => void; overlayClassName?: string; }; export const ModalContent = forwardRef( ( - { children, title, subTitle, className, overlayClassName, footerContent, onClose, ...props }, + { + children, + title, + subTitle, + className, + overlayClassName, + footerContent, + bodyClassName, + onClose, + ...props + }, forwardedRef ) => ( @@ -35,7 +46,10 @@ export const ModalContent = forwardRef( style={{ maxHeight: "90%" }} > {title && {title}} - + {children} {footerContent && {footerContent}} diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx index d8c554524..6ddd2d097 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx @@ -1,19 +1,12 @@ -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; import Link from "next/link"; import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; import { createNotification } from "@app/components/notifications"; -import { - Button, - FormControl, - Modal, - ModalClose, - ModalContent, - Select, - SelectItem -} from "@app/components/v2"; +import { Button, FormControl, Modal, ModalClose, ModalContent } from "@app/components/v2"; +import { ComboBox } from "@app/components/v2/ComboBox"; import { useOrganization, useWorkspace } from "@app/context"; import { useAddIdentityToWorkspace, @@ -25,8 +18,14 @@ import { UsePopUpState } from "@app/hooks/usePopUp"; const schema = yup .object({ - identityId: yup.string().required("Identity id is required"), - role: yup.string() + identity: yup.object({ + 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(); @@ -56,7 +55,11 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { }); const identityMemberships = identityMembershipsData?.identityMemberships; - const { data: roles } = useGetProjectRoles(projectSlug); + const { + data: roles, + isLoading: isRolesLoading, + isFetched: isRolesFetched + } = useGetProjectRoles(projectSlug); const { mutateAsync: addIdentityToWorkspaceMutateAsync } = useAddIdentityToWorkspace(); @@ -74,17 +77,24 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { control, handleSubmit, reset, + setValue, formState: { isSubmitting } } = useForm({ 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 { await addIdentityToWorkspaceMutateAsync({ workspaceId, - identityId, - role: role || undefined + identityId: identity.id, + role: role.slug || undefined }); createNotification({ @@ -92,7 +102,17 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { 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); } catch (err) { console.error(err); @@ -114,34 +134,41 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { reset(); }} > - + {filteredIdentityMembershipOrgs.length ? (
( - + by="id" + value={{ id: field.value.id, name: field.value.name }} + defaultValue={{ id: field.value.id, name: field.value.name }} + onSelectChange={(value) => onChange({ id: value.id, name: value.name })} + displayValue={(el) => el.name} + onFilter={({ value }, filterQuery) => + value.name.toLowerCase().includes(filterQuery.toLowerCase()) + } + items={filteredIdentityMembershipOrgs.map(({ identity }) => ({ + key: identity.id, + value: { id: identity.id, name: identity.name }, + label: identity.name + }))} + /> )} /> ( { isError={Boolean(error)} className="mt-4" > - + by="slug" + value={{ slug: field.value.slug, name: field.value.name }} + defaultValue={{ slug: field.value.slug, name: field.value.name }} + onSelectChange={(value) => onChange({ slug: value.slug, name: value.name })} + displayValue={(el) => el.name} + onFilter={({ value }, filterQuery) => + value.name.toLowerCase().includes(filterQuery.toLowerCase()) + } + items={(roles || []).map(({ slug, name }) => ({ + key: slug, + value: { slug, name }, + label: name + }))} + /> )} /> @@ -187,7 +218,9 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { All identities in your organization have already been added to this project. - + )}