Skip to content

Commit

Permalink
Merge pull request #1601 from rockingrohit9639/feature/import-csv-users
Browse files Browse the repository at this point in the history
Feature/import csv users
  • Loading branch information
DonKoko authored Jan 31, 2025
2 parents 0d311c8 + fe53d78 commit 8849d33
Show file tree
Hide file tree
Showing 12 changed files with 692 additions and 12 deletions.
200 changes: 200 additions & 0 deletions app/components/settings/import-users-dialog/import-users-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { cloneElement, useState } from "react";
import { useNavigate } from "@remix-run/react";
import { UploadIcon } from "lucide-react";
import type { z } from "zod";
import useFetcherWithReset from "~/hooks/use-fetcher-with-reset";
import { isFormProcessing } from "~/utils/form";
import { tw } from "~/utils/tw";
import Input from "../../forms/input";
import { Dialog, DialogPortal } from "../../layout/dialog";
import { Button } from "../../shared/button";
import { WarningBox } from "../../shared/warning-box";
import When from "../../when/when";
import type { InviteUserFormSchema } from "../invite-user-dialog";
import ImportUsersSuccessContent from "./import-users-success-content";

type ImportUsersDialogProps = {
className?: string;
trigger?: React.ReactElement<{ onClick: () => void }>;
};

type ImportUser = z.infer<typeof InviteUserFormSchema>;

export type FetcherData = {
error?: { message?: string };
success?: boolean;
inviteSentUsers?: ImportUser[];
skippedUsers?: ImportUser[];
extraMessage?: string;
};

export default function ImportUsersDialog({
className,
trigger,
}: ImportUsersDialogProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File>();
const [error, setError] = useState<string>("");

const navigate = useNavigate();

const fetcher = useFetcherWithReset<FetcherData>();
const disabled = isFormProcessing(fetcher.state);

function openDialog() {
setIsDialogOpen(true);
}

function closeDialog() {
fetcher.reset();
setIsDialogOpen(false);
}

function handleSelectFile(event: React.ChangeEvent<HTMLInputElement>) {
setError("");

const file = event.target.files?.[0];
if (file?.type !== "text/csv") {
setError("Invalid file type. Please select a CSV file.");
return;
}

setSelectedFile(file);
}

function goToInvites() {
navigate("/settings/team/invites");
closeDialog();
}

return (
<>
{trigger ? (
cloneElement(trigger, { onClick: openDialog })
) : (
<Button
variant="secondary"
className="mt-2 w-full md:mt-0 md:w-max"
onClick={openDialog}
>
<span className="whitespace-nowrap">Import Users</span>
</Button>
)}

<DialogPortal>
<Dialog
className={tw(
"h-[calc(100vh_-_50px)] overflow-auto",
!fetcher.data?.success && "md:w-[calc(100vw_-_200px)]",
className
)}
open={isDialogOpen}
onClose={closeDialog}
title={
<div className="mt-4 inline-flex items-center justify-center rounded-full border-4 border-solid border-primary-50 bg-primary-100 p-1.5 text-primary">
<UploadIcon />
</div>
}
>
{fetcher.data?.success ? (
<ImportUsersSuccessContent
data={fetcher.data}
onClose={closeDialog}
onViewInvites={goToInvites}
/>
) : (
<div className="px-6 pb-4 pt-2">
<h3>Invite Users via CSV Upload</h3>
<p>
Invite multiple users to your organization by uploading a CSV
file. To get started,{" "}
<Button
variant="link"
to="/static/shelf.nu-example-import-users-from-content.csv"
target="_blank"
download
>
download our CSV template.
</Button>
</p>
<WarningBox className="my-4">
<>
<strong>IMPORTANT</strong>: Use the provided template to
ensure proper formatting. Uploading incorrectly formatted
files may cause errors.
</>
</WarningBox>
<h4>Base Rules and Limitations</h4>
<ul className="list-inside list-disc">
<li>
You must use <b>, (comma)</b> as a delimiter in your CSV file.
</li>
<li>
Only valid roles are <b>ADMIN</b>, <b>BASE</b> and{" "}
<b>SELF_SERVICE</b>. Role column is case-sensitive.
</li>
<li>
Each row represents a new user to be invited. Ensure the email
column is valid.
</li>
<li>
Invited users will receive an email with a link to join the
organization.
</li>
</ul>

<h4 className="mt-2">Extra Considerations</h4>
<ul className="mb-4 list-inside list-disc">
<li>
The first row of the sheet will be ignored. Use it for column
headers as in the provided template.
</li>
</ul>

<p className="mb-4">
Once you've uploaded your file, a summary of the processed
invitations will be displayed, along with any errors
encountered.
</p>

<fetcher.Form
action="/api/settings/import-users"
method="POST"
encType="multipart/form-data"
>
<Input
inputType="textarea"
label="Enter your message to user"
name="message"
className="mb-2"
disabled={disabled}
rows={5}
/>

<Input
type="file"
name="file"
label="Select a csv file"
required
accept=".csv"
className="mb-2"
error={error}
onChange={handleSelectFile}
disabled={disabled}
/>

<When truthy={!!fetcher?.data?.error}>
<p className="mb-2 text-sm text-error-500">
{fetcher.data?.error?.message}
</p>
</When>

<Button disabled={!selectedFile || disabled}>Import now</Button>
</fetcher.Form>
</div>
)}
</Dialog>
</DialogPortal>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { ClientOnly } from "remix-utils/client-only";
import { Button } from "~/components/shared/button";
import { WarningBox } from "~/components/shared/warning-box";
import When from "~/components/when/when";
import SuccessAnimation from "~/components/zxing-scanner/success-animation";
import { tw } from "~/utils/tw";
import type { FetcherData } from "./import-users-dialog";
import ImportUsersTable from "./import-users-table";

type ImportUsersSuccessContentProps = {
className?: string;
data: FetcherData;
onClose: () => void;
onViewInvites: () => void;
};

export default function ImportUsersSuccessContent({
className,
data,
onClose,
onViewInvites,
}: ImportUsersSuccessContentProps) {
return (
<div
className={tw(
"flex flex-col items-center justify-center px-6 pb-4 text-center",
className
)}
>
<ClientOnly fallback={null}>{() => <SuccessAnimation />}</ClientOnly>

<h4>Import completed</h4>
<p className="mb-4">
Users from the csv file has been invited. Below you can find a summary
of the invited users.
</p>

<When truthy={!!data.extraMessage}>
<WarningBox className="mb-4 w-full">
{data.extraMessage ?? ""}
</WarningBox>
</When>

<When truthy={!!data?.inviteSentUsers?.length}>
<ImportUsersTable
className="mb-4"
title="Invited users"
users={data?.inviteSentUsers ?? []}
/>
</When>
<When truthy={!!data?.skippedUsers?.length}>
<ImportUsersTable
className="mb-4"
title="Skipped users"
users={data?.skippedUsers ?? []}
/>
</When>

<div className="flex w-full items-center gap-2">
<Button variant="secondary" onClick={onClose} className="flex-1">
Close
</Button>
<Button onClick={onViewInvites} className="flex-1">
View Invites
</Button>
</div>
</div>
);
}
51 changes: 51 additions & 0 deletions app/components/settings/import-users-dialog/import-users-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { z } from "zod";
import { organizationRolesMap } from "~/routes/_layout+/settings.team";
import { tw } from "~/utils/tw";
import type { InviteUserFormSchema } from "../invite-user-dialog";

type ImportUsersTableProps = {
className?: string;
style?: React.CSSProperties;
title: string;
users: z.infer<typeof InviteUserFormSchema>[];
};

export default function ImportUsersTable({
className,
style,
title,
users,
}: ImportUsersTableProps) {
return (
<div
className={tw(
"relative w-full overflow-x-auto rounded-md border",
className
)}
style={style}
>
<h4 className="px-6 py-3 text-left">{title}</h4>

<table className="w-full text-left text-sm">
<thead className="bg-gray-50 text-xs uppercase">
<tr>
<th scope="col" className="px-6 py-3">
Email
</th>
<th scope="col" className="px-6 py-3">
Role
</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.email}>
<td className="px-6 py-4">{user.email}</td>
<td className="px-6 py-4">{organizationRolesMap[user.role]}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
13 changes: 11 additions & 2 deletions app/components/settings/invite-user-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,17 @@ export const InviteUserFormSchema = z.object({
message: "Please enter a valid email",
})),
teamMemberId: z.string().optional(),
role: z.nativeEnum(OrganizationRoles, { message: "Please select a role." }),
role: z.preprocess(
(value) => String(value).trim().toUpperCase(),
z.enum(
[
OrganizationRoles.ADMIN,
OrganizationRoles.BASE,
OrganizationRoles.SELF_SERVICE,
],
{ message: "Please select a role" }
)
),
});

const organizationRolesMap: Record<string, UserFriendlyRoles> = {
Expand Down Expand Up @@ -121,7 +131,6 @@ export default function InviteUserDialog({
method="post"
className="flex flex-col gap-3"
>
{/* <input type="hidden" name="redirectTo" value={redirectTo} /> */}
<When truthy={!!teamMemberId}>
<input
type="hidden"
Expand Down
2 changes: 1 addition & 1 deletion app/components/shared/warning-box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function WarningBox({
return (
<div
className={tw(
" b-warning-300 relative rounded border bg-warning-25 p-4 text-sm text-warning-700",
"relative rounded border border-warning-300 bg-warning-25 p-4 text-sm text-warning-700",
visible ? "block" : "hidden",
rest?.className || ""
)}
Expand Down
Loading

0 comments on commit 8849d33

Please sign in to comment.