improvement: add filter select to add identity to project modals

This commit is contained in:
Scott Wilson
2024-11-26 18:40:45 -08:00
parent ad8d247cdc
commit 90374971ae
4 changed files with 198 additions and 213 deletions
@@ -4,7 +4,14 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; import {
Button,
FilterableSelect,
FormControl,
Modal,
ModalClose,
ModalContent
} from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context"; import { useOrganization, useWorkspace } from "@app/context";
import { import {
useAddIdentityToWorkspace, useAddIdentityToWorkspace,
@@ -16,8 +23,8 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z const schema = z
.object({ .object({
projectId: z.string(), project: z.object({ name: z.string(), id: z.string() }),
role: z.string() role: z.object({ name: z.string(), slug: z.string() })
}) })
.required(); .required();
@@ -32,7 +39,9 @@ type Props = {
) => void; ) => void;
}; };
export const IdentityAddToProjectModal = ({ identityId, popUp, handlePopUpToggle }: Props) => { // TODO: eventually refactor to support adding to multiple projects at once? would lose role granularity unique to project
const Content = ({ identityId, handlePopUpToggle }: Omit<Props, "popUp">) => {
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { workspaces } = useWorkspace(); const { workspaces } = useWorkspace();
const { mutateAsync: addIdentityToWorkspace } = useAddIdentityToWorkspace(); const { mutateAsync: addIdentityToWorkspace } = useAddIdentityToWorkspace();
@@ -47,10 +56,10 @@ export const IdentityAddToProjectModal = ({ identityId, popUp, handlePopUpToggle
resolver: zodResolver(schema) resolver: zodResolver(schema)
}); });
const projectId = watch("projectId"); const projectId = watch("project")?.id;
const { data: projectMemberships } = useGetIdentityProjectMemberships(identityId); const { data: projectMemberships } = useGetIdentityProjectMemberships(identityId);
const { data: project } = useGetWorkspaceById(projectId); const { data: project, isLoading: isProjectLoading } = useGetWorkspaceById(projectId);
const { data: roles } = useGetProjectRoles(project?.id ?? ""); const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(project?.id ?? "");
const filteredWorkspaces = useMemo(() => { const filteredWorkspaces = useMemo(() => {
const wsWorkspaceIds = new Map(); const wsWorkspaceIds = new Map();
@@ -64,12 +73,12 @@ export const IdentityAddToProjectModal = ({ identityId, popUp, handlePopUpToggle
); );
}, [workspaces, projectMemberships]); }, [workspaces, projectMemberships]);
const onFormSubmit = async ({ projectId: workspaceId, role }: FormData) => { const onFormSubmit = async ({ project: selectedProject, role }: FormData) => {
try { try {
await addIdentityToWorkspace({ await addIdentityToWorkspace({
workspaceId, workspaceId: selectedProject.id,
identityId, identityId,
role: role || undefined role: role.slug || undefined
}); });
createNotification({ createNotification({
@@ -91,65 +100,52 @@ export const IdentityAddToProjectModal = ({ identityId, popUp, handlePopUpToggle
} }
}; };
const isProjectSelected = Boolean(projectId);
return ( return (
<Modal
isOpen={popUp?.addIdentityToProject?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addIdentityToProject", isOpen);
reset();
}}
>
<ModalContent title="Add Identity to Project">
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={handleSubmit(onFormSubmit)}>
<Controller <Controller
control={control} control={control}
name="projectId" name="project"
defaultValue="" render={({ field: { onChange, value }, fieldState: { error } }) => (
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl <FormControl
label="Project" label="Projects"
errorText={error?.message} errorText={error?.message}
isError={Boolean(error)} isError={Boolean(error)}
className="mt-4" className="mt-4"
> >
<Select <FilterableSelect
defaultValue={field.value} value={value}
{...field} onChange={onChange}
onValueChange={(e) => onChange(e)} options={filteredWorkspaces}
className="w-full" placeholder="Select project..."
> getOptionValue={(option) => option.id}
{(filteredWorkspaces || []).map(({ id, name }) => ( getOptionLabel={(option) => option.name}
<SelectItem value={id} key={`project-${id}`}> isLoading={isProjectSelected && isProjectLoading}
{name} />
</SelectItem>
))}
</Select>
</FormControl> </FormControl>
)} )}
/> />
<Controller <Controller
control={control} control={control}
name="role" name="role"
defaultValue="" render={({ field: { onChange, value }, fieldState: { error } }) => (
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl <FormControl
label="Role" label="Role"
errorText={error?.message} errorText={error?.message}
isError={Boolean(error)} isError={Boolean(error)}
className="mt-4" className="mt-4"
> >
<Select <FilterableSelect
defaultValue={field.value} isDisabled={!isProjectSelected}
{...field} value={value}
onValueChange={(e) => onChange(e)} onChange={onChange}
className="w-full" options={roles}
> isLoading={isProjectSelected && isRolesLoading}
{(roles || []).map(({ name, slug }) => ( placeholder="Select role..."
<SelectItem value={slug} key={`project-role-${slug}`}> getOptionValue={(option) => option.slug}
{name} getOptionLabel={(option) => option.name}
</SelectItem> />
))}
</Select>
</FormControl> </FormControl>
)} )}
/> />
@@ -163,15 +159,26 @@ export const IdentityAddToProjectModal = ({ identityId, popUp, handlePopUpToggle
> >
Add Add
</Button> </Button>
<Button <ModalClose asChild>
colorSchema="secondary" <Button colorSchema="secondary" variant="plain">
variant="plain"
onClick={() => handlePopUpToggle("addIdentityToProject", false)}
>
Cancel Cancel
</Button> </Button>
</ModalClose>
</div> </div>
</form> </form>
);
};
export const IdentityAddToProjectModal = ({ identityId, popUp, handlePopUpToggle }: Props) => {
return (
<Modal
isOpen={popUp?.addIdentityToProject?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addIdentityToProject", isOpen);
}}
>
<ModalContent bodyClassName="overflow-visible" title="Add Identity to Projects">
<Content identityId={identityId} handlePopUpToggle={handlePopUpToggle} />
</ModalContent> </ModalContent>
</Modal> </Modal>
); );
@@ -42,7 +42,7 @@ export const IdentitySection = withPermission(
? subscription.identitiesUsed < subscription.identityLimit ? subscription.identitiesUsed < subscription.identityLimit
: true; : true;
const isEnterprise = subscription?.slug === "enterprise" const isEnterprise = subscription?.slug === "enterprise";
const onDeleteIdentitySubmit = async (identityId: string) => { const onDeleteIdentitySubmit = async (identityId: string) => {
try { try {
@@ -105,7 +105,7 @@ export const IdentitySection = withPermission(
}} }}
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
Create identity Create Identity
</Button> </Button>
)} )}
</OrgPermissionCan> </OrgPermissionCan>
@@ -181,7 +181,7 @@ export const IdentityTab = withProjectPermission(
onClick={() => handlePopUpOpen("identity")} onClick={() => handlePopUpOpen("identity")}
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
Add identity Add Identity
</Button> </Button>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
@@ -1,12 +1,19 @@
import { useEffect, useMemo } from "react"; import { 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 { zodResolver } from "@hookform/resolvers/zod";
import * as yup from "yup"; import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Modal, ModalClose, ModalContent } from "@app/components/v2"; import {
import { ComboBox } from "@app/components/v2/ComboBox"; Button,
FilterableSelect,
FormControl,
Modal,
ModalClose,
ModalContent,
Spinner
} from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context"; import { useOrganization, useWorkspace } from "@app/context";
import { import {
useAddIdentityToWorkspace, useAddIdentityToWorkspace,
@@ -16,34 +23,27 @@ import {
} from "@app/hooks/api"; } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = yup const schema = z.object({
.object({ identity: z.object({ name: z.string(), id: z.string() }),
identity: yup.object({ role: z.object({ name: z.string(), slug: z.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();
export type FormData = yup.InferType<typeof schema>; export type FormData = z.infer<typeof schema>;
type Props = { type Props = {
popUp: UsePopUpState<["identity"]>; popUp: UsePopUpState<["identity"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["identity"]>, state?: boolean) => void; handlePopUpToggle: (popUpName: keyof UsePopUpState<["identity"]>, state?: boolean) => void;
}; };
export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { const Content = ({ popUp, handlePopUpToggle }: Props) => {
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const organizationId = currentOrg?.id || ""; const organizationId = currentOrg?.id || "";
const workspaceId = currentWorkspace?.id || ""; const workspaceId = currentWorkspace?.id || "";
const { data: identityMembershipOrgsData } = useGetIdentityMembershipOrgs({ const { data: identityMembershipOrgsData, isLoading: isMembershipsLoading } =
useGetIdentityMembershipOrgs({
organizationId, organizationId,
limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will replace with combobox in separate PR limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will replace with combobox in separate PR
}); });
@@ -54,11 +54,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
}); });
const identityMemberships = identityMembershipsData?.identityMemberships; const identityMemberships = identityMembershipsData?.identityMemberships;
const { const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
data: roles,
isLoading: isRolesLoading,
isFetched: isRolesFetched
} = useGetProjectRoles(workspaceId);
const { mutateAsync: addIdentityToWorkspaceMutateAsync } = useAddIdentityToWorkspace(); const { mutateAsync: addIdentityToWorkspaceMutateAsync } = useAddIdentityToWorkspace();
@@ -76,18 +72,11 @@ 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: zodResolver(schema)
}); });
useEffect(() => {
if (!isRolesFetched || !roles) return;
setValue("role", { name: roles[0]?.name, slug: roles[0]?.slug });
}, [isRolesFetched, roles]);
const onFormSubmit = async ({ identity, role }: FormData) => { const onFormSubmit = async ({ identity, role }: FormData) => {
try { try {
await addIdentityToWorkspaceMutateAsync({ await addIdentityToWorkspaceMutateAsync({
@@ -125,41 +114,27 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
} }
}; };
if (isMembershipsLoading || isRolesLoading)
return ( return (
<Modal <div className="flex w-full items-center justify-center py-10">
isOpen={popUp?.identity?.isOpen} <Spinner className="text-mineshaft-400" />
onOpenChange={(isOpen) => { </div>
handlePopUpToggle("identity", isOpen); );
reset();
}} return filteredIdentityMembershipOrgs.length ? (
>
<ModalContent title="Add Identity to Project" bodyClassName="overflow-visible">
{filteredIdentityMembershipOrgs.length ? (
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={handleSubmit(onFormSubmit)}>
<Controller <Controller
control={control} control={control}
name="identity" name="identity"
defaultValue={{ render={({ field: { onChange, value }, fieldState: { error } }) => (
id: filteredIdentityMembershipOrgs?.[0]?.identity?.id,
name: filteredIdentityMembershipOrgs?.[0]?.identity?.name
}}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Identity" errorText={error?.message} isError={Boolean(error)}> <FormControl label="Identity" errorText={error?.message} isError={Boolean(error)}>
<ComboBox <FilterableSelect
className="w-full" value={value}
by="id" onChange={onChange}
value={{ id: field.value.id, name: field.value.name }} placeholder="Select identity..."
defaultValue={{ id: field.value.id, name: field.value.name }} options={filteredIdentityMembershipOrgs.map((membership) => membership.identity)}
onSelectChange={(value) => onChange({ id: value.id, name: value.name })} getOptionValue={(option) => option.id}
displayValue={(el) => el.name} getOptionLabel={(option) => option.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
}))}
/> />
</FormControl> </FormControl>
)} )}
@@ -167,29 +142,20 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller <Controller
control={control} control={control}
name="role" name="role"
defaultValue={{ name: "", slug: "" }} render={({ field: { onChange, value }, fieldState: { error } }) => (
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl <FormControl
label="Role" label="Role"
errorText={error?.message} errorText={error?.message}
isError={Boolean(error)} isError={Boolean(error)}
className="mt-4" className="mt-4"
> >
<ComboBox <FilterableSelect
className="w-full" value={value}
by="slug" onChange={onChange}
value={{ slug: field.value.slug, name: field.value.name }} options={roles}
defaultValue={{ slug: field.value.slug, name: field.value.name }} placeholder="Select role..."
onSelectChange={(value) => onChange({ slug: value.slug, name: value.name })} getOptionValue={(option) => option.slug}
displayValue={(el) => el.name} getOptionLabel={(option) => option.name}
onFilter={({ value }, filterQuery) =>
value.name.toLowerCase().includes(filterQuery.toLowerCase())
}
items={(roles || []).map(({ slug, name }) => ({
key: slug,
value: { slug, name },
label: name
}))}
/> />
</FormControl> </FormControl>
)} )}
@@ -222,7 +188,19 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
</Button> </Button>
</Link> </Link>
</div> </div>
)} );
};
export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
return (
<Modal
isOpen={popUp?.identity?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("identity", isOpen);
}}
>
<ModalContent title="Add Identity to Project" bodyClassName="overflow-visible">
<Content popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
</ModalContent> </ModalContent>
</Modal> </Modal>
); );