From 66e5500423615d1cad21d1db336f3bfe07d48c15 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Thu, 9 Jan 2025 13:46:25 +0100 Subject: [PATCH 1/2] refactor(invite-user): make invite user feature client side --- .../settings/invite-user-dialog.tsx | 251 +++++++++++++++ .../workspace/nrm-actions-dropdown.tsx | 125 ++++---- .../settings.team.invites.invite-user.tsx | 299 ------------------ app/routes/_layout+/settings.team.invites.tsx | 18 +- app/routes/_layout+/settings.team.users.tsx | 18 +- app/routes/api+/settings.invite-user.ts | 101 ++++++ 6 files changed, 443 insertions(+), 369 deletions(-) create mode 100644 app/components/settings/invite-user-dialog.tsx delete mode 100644 app/routes/_layout+/settings.team.invites.invite-user.tsx create mode 100644 app/routes/api+/settings.invite-user.ts diff --git a/app/components/settings/invite-user-dialog.tsx b/app/components/settings/invite-user-dialog.tsx new file mode 100644 index 000000000..1a95a28ca --- /dev/null +++ b/app/components/settings/invite-user-dialog.tsx @@ -0,0 +1,251 @@ +import { cloneElement, useCallback, useEffect, useState } from "react"; +import { OrganizationRoles } from "@prisma/client"; +import { useActionData, useLocation, useNavigation } from "@remix-run/react"; +import { UserIcon } from "lucide-react"; +import { useZorm } from "react-zorm"; +import { z } from "zod"; +import { useSearchParams } from "~/hooks/search-params"; +import { useCurrentOrganization } from "~/hooks/use-current-organization-id"; +import type { UserFriendlyRoles } from "~/routes/_layout+/settings.team"; +import { isFormProcessing } from "~/utils/form"; +import { validEmail } from "~/utils/misc"; +import { Form } from "../custom-form"; +import Input from "../forms/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "../forms/select"; +import { Dialog, DialogPortal } from "../layout/dialog"; +import { Button } from "../shared/button"; +import { Image } from "../shared/image"; +import When from "../when/when"; + +type InviteUserDialogProps = { + className?: string; + teamMemberId?: string; + trigger?: React.ReactElement<{ onClick: () => void }>; + open?: boolean; + onClose?: () => void; +}; + +export const InviteUserFormSchema = z.object({ + email: z + .string() + .transform((email) => email.toLowerCase()) + .refine(validEmail, () => ({ + message: "Please enter a valid email", + })), + teamMemberId: z.string().optional(), + role: z.nativeEnum(OrganizationRoles), + redirectTo: z.string().optional(), +}); + +const organizationRolesMap: Record = { + [OrganizationRoles.ADMIN]: "Administrator", + [OrganizationRoles.BASE]: "Base", + [OrganizationRoles.SELF_SERVICE]: "Self service", +}; + +export default function InviteUserDialog({ + className, + trigger, + teamMemberId, + open = false, + onClose, +}: InviteUserDialogProps) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); + const organization = useCurrentOrganization(); + const navigation = useNavigation(); + const { pathname } = useLocation(); + + const actionData = useActionData<{ error?: { message: string } }>(); + + const disabled = isFormProcessing(navigation.state); + + const zo = useZorm("NewQuestionWizardScreen", InviteUserFormSchema); + + const redirectTo = `${pathname}${ + searchParams.size > 0 + ? `?${searchParams.toString()}&success=true` + : "?success=true" + }`; + + function openDialog() { + setIsDialogOpen(true); + } + + const closeDialog = useCallback(() => { + zo.form?.reset(); + setIsDialogOpen(false); + onClose && onClose(); + }, [onClose, zo.form]); + + useEffect( + function handleSuccess() { + if (searchParams.get("success") === "true") { + closeDialog(); + + setSearchParams((prev) => { + prev.delete("success"); + return prev; + }); + } + }, + [closeDialog, searchParams, setSearchParams] + ); + + if (!organization) { + return null; + } + + return ( + <> + {trigger ? cloneElement(trigger, { onClick: openDialog }) : null} + + + + + + } + open={isDialogOpen || open} + onClose={closeDialog} + > +
+
+

Invite team members

+

+ Invite a user to this workspace. Make sure to give them the + proper role. +

+
+ +
+ + + + + + + Workspace + + + + + Role + + + +
+ +
+ + +
+ {actionData?.error?.message} +
+
+ +
+ + +
+
+
+
+
+ + ); +} diff --git a/app/components/workspace/nrm-actions-dropdown.tsx b/app/components/workspace/nrm-actions-dropdown.tsx index 2b68592ea..fa5e90251 100644 --- a/app/components/workspace/nrm-actions-dropdown.tsx +++ b/app/components/workspace/nrm-actions-dropdown.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import type { Prisma } from "@prisma/client"; import { useLoaderData } from "@remix-run/react"; import { VerticalDotsIcon } from "~/components/icons/library"; @@ -10,8 +11,8 @@ import { import { useControlledDropdownMenu } from "~/hooks/use-controlled-dropdown-menu"; import type { loader } from "~/routes/_layout+/settings.team.nrm"; -import { tw } from "~/utils/tw"; import { DeleteMember } from "./delete-member"; +import InviteUserDialog from "../settings/invite-user-dialog"; import { Button } from "../shared/button"; export function TeamMembersActionsDropdown({ @@ -27,68 +28,80 @@ export function TeamMembersActionsDropdown({ }; }>; }) { + const [isInviteOpen, setIsInviteOpen] = useState(false); const { isPersonalOrg } = useLoaderData(); const { ref, open, setOpen } = useControlledDropdownMenu(); return ( - setOpen(open)} - open={open} - > - + setOpen(open)} + open={open} > - - - - - - - - + + - - - + + + + + + + - - - + { + setIsInviteOpen(false); + }} + /> + ); } diff --git a/app/routes/_layout+/settings.team.invites.invite-user.tsx b/app/routes/_layout+/settings.team.invites.invite-user.tsx deleted file mode 100644 index 94d4249a9..000000000 --- a/app/routes/_layout+/settings.team.invites.invite-user.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import { OrganizationRoles } from "@prisma/client"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; -import { json, redirect } from "@remix-run/node"; -import { useActionData, useNavigation } from "@remix-run/react"; -import { useZorm } from "react-zorm"; -import z from "zod"; -import { Form } from "~/components/custom-form"; -import Input from "~/components/forms/input"; -import { - Select, - SelectGroup, - SelectContent, - SelectItem, - SelectLabel, - SelectValue, - SelectTrigger, -} from "~/components/forms/select"; -import { UserIcon } from "~/components/icons/library"; -import { Button } from "~/components/shared/button"; -import { Image } from "~/components/shared/image"; -import { db } from "~/database/db.server"; -import { useSearchParams } from "~/hooks/search-params"; -import { useCurrentOrganization } from "~/hooks/use-current-organization-id"; -import { createInvite } from "~/modules/invite/service.server"; -import styles from "~/styles/layout/custom-modal.css?url"; -import { sendNotification } from "~/utils/emitter/send-notification.server"; -import { ShelfError, makeShelfError } from "~/utils/error"; -import { isFormProcessing } from "~/utils/form"; -import { data, error, parseData } from "~/utils/http.server"; -import { validEmail } from "~/utils/misc"; -import { - PermissionAction, - PermissionEntity, -} from "~/utils/permissions/permission.data"; -import { requirePermission } from "~/utils/roles.server"; -import { assertUserCanInviteUsersToWorkspace } from "~/utils/subscription.server"; -import { tw } from "~/utils/tw"; -import type { UserFriendlyRoles } from "./settings.team"; - -const InviteUserFormSchema = z.object({ - email: z - .string() - .transform((email) => email.toLowerCase()) - .refine(validEmail, () => ({ - message: "Please enter a valid email", - })), - teamMemberId: z.string().optional(), - role: z.nativeEnum(OrganizationRoles), -}); - -export const loader = async ({ context, request }: LoaderFunctionArgs) => { - const authSession = context.getSession(); - const { userId } = authSession; - - try { - const { organizationId } = await requirePermission({ - userId, - request, - entity: PermissionEntity.teamMember, - action: PermissionAction.create, - }); - - await assertUserCanInviteUsersToWorkspace({ organizationId }); - - return json( - data({ - showModal: true, - }) - ); - } catch (cause) { - const reason = makeShelfError(cause); - throw json(error(reason), { status: reason.status }); - } -}; - -export const action = async ({ context, request }: ActionFunctionArgs) => { - const authSession = context.getSession(); - const { userId } = authSession; - - try { - const { organizationId } = await requirePermission({ - userId, - request, - entity: PermissionEntity.teamMember, - action: PermissionAction.create, - }); - - const { email, teamMemberId, role } = parseData( - await request.formData(), - InviteUserFormSchema - ); - - let teamMemberName = email.split("@")[0]; - - if (teamMemberId) { - const teamMember = await db.teamMember - .findUnique({ - where: { deletedAt: null, id: teamMemberId }, - }) - .catch((cause) => { - throw new ShelfError({ - cause, - message: "Failed to get team member", - additionalData: { teamMemberId, userId }, - label: "Team", - }); - }); - - if (teamMember) { - teamMemberName = teamMember.name; - } - } - - const existingInvites = await db.invite.findMany({ - where: { - status: "PENDING", - inviteeEmail: email, - organizationId, - }, - }); - - if (existingInvites.length) { - throw new ShelfError({ - cause: null, - message: - "User already has a pending invite. Either resend it or cancel it in order to be able to send a new one.", - additionalData: { email, organizationId }, - label: "Invite", - shouldBeCaptured: false, - }); - } - - const invite = await createInvite({ - organizationId, - inviteeEmail: email, - inviterId: userId, - roles: [role], - teamMemberName, - teamMemberId, - userId, - }); - - if (invite) { - sendNotification({ - title: "Successfully invited user", - message: - "They will receive an email in which they can complete their registration.", - icon: { name: "success", variant: "success" }, - senderId: userId, - }); - - return redirect("/settings/team/invites"); - } - - return json(data(null)); - } catch (cause) { - const reason = makeShelfError(cause, {}); - return json(error(reason), { status: reason.status }); - } -}; - -export function links() { - return [{ rel: "stylesheet", href: styles }]; -} -export const handle = { - name: "settings.team.invites.invite-user", -}; - -const organizationRolesMap: Record = { - [OrganizationRoles.ADMIN]: "Administrator", - [OrganizationRoles.BASE]: "Base", - [OrganizationRoles.SELF_SERVICE]: "Self service", -}; - -export default function InviteUser() { - const organization = useCurrentOrganization(); - const zo = useZorm("NewQuestionWizardScreen", InviteUserFormSchema); - const navigation = useNavigation(); - const disabled = isFormProcessing(navigation.state); - const [searchParams] = useSearchParams(); - const teamMemberId = searchParams.get("teamMemberId"); - - const actionData = useActionData(); - return organization ? ( - <> -
-
- -
-
-

Invite team members

-

- Invite a user to this workspace. Make sure to give them the proper - role. -

-
-
- {teamMemberId ? ( - - ) : null} - - Workspace - - - - - Role - - - -
- -
- - {actionData?.error ? ( -
- {actionData.error.message} -
- ) : null} - -
- - -
-
-
- - ) : null; -} diff --git a/app/routes/_layout+/settings.team.invites.tsx b/app/routes/_layout+/settings.team.invites.tsx index 046db9faa..17ab977bb 100644 --- a/app/routes/_layout+/settings.team.invites.tsx +++ b/app/routes/_layout+/settings.team.invites.tsx @@ -11,6 +11,7 @@ import type { HeaderData } from "~/components/layout/header/types"; import { List } from "~/components/list"; import { ListContentWrapper } from "~/components/list/content-wrapper"; import { Filters } from "~/components/list/filters"; +import InviteUserDialog from "~/components/settings/invite-user-dialog"; import { Button } from "~/components/shared/button"; import { InfoTooltip } from "~/components/shared/info-tooltip"; import { Td, Th } from "~/components/table"; @@ -159,13 +160,16 @@ export default function UserInvitesSetting() { - + + Invite a user + + } + /> - + + Invite a user + + } + /> { + throw new ShelfError({ + cause, + message: "Failed to get team member", + additionalData: { teamMemberId, userId }, + label: "Team", + }); + }); + + if (teamMember) { + teamMemberName = teamMember.name; + } + } + + const existingInvites = await db.invite.findMany({ + where: { + status: "PENDING", + inviteeEmail: email, + organizationId, + }, + }); + + if (existingInvites.length) { + throw new ShelfError({ + cause: null, + message: + "User already has a pending invite. Either resend it or cancel it in order to be able to send a new one.", + additionalData: { email, organizationId }, + label: "Invite", + shouldBeCaptured: false, + }); + } + + const invite = await createInvite({ + organizationId, + inviteeEmail: email, + inviterId: userId, + roles: [role], + teamMemberName, + teamMemberId, + userId, + }); + + if (invite) { + sendNotification({ + title: "Successfully invited user", + message: + "They will receive an email in which they can complete their registration.", + icon: { name: "success", variant: "success" }, + senderId: userId, + }); + + return redirect(safeRedirect(redirectTo)); + } + + return json(data(null)); + } catch (cause) { + const reason = makeShelfError(cause, { userId }); + return json(error(reason), { status: reason.status }); + } +} From 06578d5dcec9190e23c265d62bba55691a375046 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Tue, 14 Jan 2025 12:02:28 +0100 Subject: [PATCH 2/2] refactor(user-invite): pr review changes --- .../settings/invite-user-dialog.tsx | 53 ++++++++----------- app/routes/api+/settings.invite-user.ts | 28 +++++----- 2 files changed, 37 insertions(+), 44 deletions(-) diff --git a/app/components/settings/invite-user-dialog.tsx b/app/components/settings/invite-user-dialog.tsx index 1a95a28ca..4ef96c4b9 100644 --- a/app/components/settings/invite-user-dialog.tsx +++ b/app/components/settings/invite-user-dialog.tsx @@ -1,15 +1,13 @@ import { cloneElement, useCallback, useEffect, useState } from "react"; import { OrganizationRoles } from "@prisma/client"; -import { useActionData, useLocation, useNavigation } from "@remix-run/react"; import { UserIcon } from "lucide-react"; import { useZorm } from "react-zorm"; import { z } from "zod"; -import { useSearchParams } from "~/hooks/search-params"; import { useCurrentOrganization } from "~/hooks/use-current-organization-id"; +import useFetcherWithReset from "~/hooks/use-fetcher-with-reset"; import type { UserFriendlyRoles } from "~/routes/_layout+/settings.team"; import { isFormProcessing } from "~/utils/form"; import { validEmail } from "~/utils/misc"; -import { Form } from "../custom-form"; import Input from "../forms/input"; import { Select, @@ -41,8 +39,7 @@ export const InviteUserFormSchema = z.object({ message: "Please enter a valid email", })), teamMemberId: z.string().optional(), - role: z.nativeEnum(OrganizationRoles), - redirectTo: z.string().optional(), + role: z.nativeEnum(OrganizationRoles, { message: "Please select a role." }), }); const organizationRolesMap: Record = { @@ -59,23 +56,17 @@ export default function InviteUserDialog({ onClose, }: InviteUserDialogProps) { const [isDialogOpen, setIsDialogOpen] = useState(false); - const [searchParams, setSearchParams] = useSearchParams(); const organization = useCurrentOrganization(); - const navigation = useNavigation(); - const { pathname } = useLocation(); - const actionData = useActionData<{ error?: { message: string } }>(); + const fetcher = useFetcherWithReset<{ + error?: { message?: string }; + success?: boolean; + }>(); - const disabled = isFormProcessing(navigation.state); + const disabled = isFormProcessing(fetcher.state); const zo = useZorm("NewQuestionWizardScreen", InviteUserFormSchema); - const redirectTo = `${pathname}${ - searchParams.size > 0 - ? `?${searchParams.toString()}&success=true` - : "?success=true" - }`; - function openDialog() { setIsDialogOpen(true); } @@ -88,16 +79,12 @@ export default function InviteUserDialog({ useEffect( function handleSuccess() { - if (searchParams.get("success") === "true") { + if (fetcher.data?.success === true) { closeDialog(); - - setSearchParams((prev) => { - prev.delete("success"); - return prev; - }); + fetcher.reset(); } }, - [closeDialog, searchParams, setSearchParams] + [closeDialog, fetcher, fetcher.data?.success] ); if (!organization) { @@ -128,13 +115,13 @@ export default function InviteUserDialog({

-
- + {/* */} + +

+ {zo.errors?.role()?.message} +

+
- -
- {actionData?.error?.message} -
+ +

+ {fetcher.data?.error?.message} +

- + diff --git a/app/routes/api+/settings.invite-user.ts b/app/routes/api+/settings.invite-user.ts index eddded325..304cc99ab 100644 --- a/app/routes/api+/settings.invite-user.ts +++ b/app/routes/api+/settings.invite-user.ts @@ -1,10 +1,10 @@ -import { json, redirect, type ActionFunctionArgs } from "@remix-run/node"; +import { json, type ActionFunctionArgs } from "@remix-run/node"; import { InviteUserFormSchema } from "~/components/settings/invite-user-dialog"; import { db } from "~/database/db.server"; import { createInvite } from "~/modules/invite/service.server"; import { sendNotification } from "~/utils/emitter/send-notification.server"; import { makeShelfError, ShelfError } from "~/utils/error"; -import { data, error, parseData, safeRedirect } from "~/utils/http.server"; +import { data, error, parseData } from "~/utils/http.server"; import { PermissionAction, PermissionEntity, @@ -26,7 +26,7 @@ export async function action({ context, request }: ActionFunctionArgs) { await assertUserCanInviteUsersToWorkspace({ organizationId }); - const { email, teamMemberId, role, redirectTo } = parseData( + const { email, teamMemberId, role } = parseData( await request.formData(), InviteUserFormSchema ); @@ -81,19 +81,19 @@ export async function action({ context, request }: ActionFunctionArgs) { userId, }); - if (invite) { - sendNotification({ - title: "Successfully invited user", - message: - "They will receive an email in which they can complete their registration.", - icon: { name: "success", variant: "success" }, - senderId: userId, - }); - - return redirect(safeRedirect(redirectTo)); + if (!invite) { + return json(data(null)); } - return json(data(null)); + sendNotification({ + title: "Successfully invited user", + message: + "They will receive an email in which they can complete their registration.", + icon: { name: "success", variant: "success" }, + senderId: userId, + }); + + return json(data({ success: true })); } catch (cause) { const reason = makeShelfError(cause, { userId }); return json(error(reason), { status: reason.status });