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}
+
+
+
+
+ >
+ );
+}
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.
-
-
-
-
- >
- ) : 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({
-
+
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 });