Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions src/contributors/components/ContributorForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@ import { z } from "zod"
import { LabelSelectField } from "src/core/components/fields/LabelSelectField"
import { useQuery } from "@blitzjs/rpc"
import { MemberPrivileges } from "@prisma/client"
import LabeledTextField from "src/core/components/fields/LabeledTextField"
import AddRoleInput from "src/roles/components/AddRoleInput"
import getProjectManagerUserIds from "src/projectmembers/queries/getProjectManagerUserIds"
import TooltipWrapper from "src/core/components/TooltipWrapper"
import { WithContext as ReactTags, SEPARATORS } from "react-tag-input"
import { InformationCircleIcon } from "@heroicons/react/24/outline"
import { Tooltip } from "react-tooltip"
import Card from "src/core/components/Card"
import LabeledTextAreaField from "src/core/components/fields/LabeledTextAreaField"

interface ContributorFormProps<S extends z.ZodType<any, any>> extends FormProps<S> {
projectId: number
Expand Down Expand Up @@ -99,8 +98,14 @@ export function ContributorForm<S extends z.ZodType<any, any>>(props: Contributo
)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault() // Prevent form submission on Enter
if (e.key === "Enter" && !e.shiftKey) {
const el = e.target as HTMLElement
const tagName = (el.tagName || "").toLowerCase()
const inTextarea = tagName === "textarea"
const inReactTags = !!el.closest(".react-tags-wrapper")
if (!inTextarea && !inReactTags) {
e.preventDefault() // Prevent accidental form submit from text inputs/buttons
}
}
}}
>
Expand All @@ -116,12 +121,12 @@ export function ContributorForm<S extends z.ZodType<any, any>>(props: Contributo
opacity={1}
/>
{!isEdit && (
<LabeledTextField
<LabeledTextAreaField
name="email"
label="Email:"
placeholder="Email"
type="text"
className="input mb-4 w-1/2 text-primary input-primary input-bordered border-2 bg-base-300"
label="Email(s):"
placeholder="Enter one or multiple emails (comma, semicolon, space, or newline separated)"
rows={4}
className="textarea textarea-primary textarea-bordered border-2 bg-base-300 text-primary mb-4 w-1/2"
/>
)}
<LabelSelectField
Expand Down
39 changes: 29 additions & 10 deletions src/invites/hooks/useInviteContributor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,26 @@ export function useInviteContributor(projectId: number) {
const router = useRouter()
const currentUser = useCurrentUser()

const handleEmailSending = async (emailData, successMessage, errorMessage) => {
const handleEmailSending = async (emailData, successMessage, errorMessage, silent = false) => {
const emailSent = await sendInvitationEmail(emailData)
if (silent) {
return emailSent
}
if (emailSent) {
toast.success(successMessage)
} else {
console.error(errorMessage)
toast.error(errorMessage)
}
return emailSent
}

const handleSubmit = async (values: any) => {
const handleSubmit = async (
values: any,
options?: { silent?: boolean; skipRedirect?: boolean }
) => {
const silent = !!options?.silent
const skipRedirect = !!options?.skipRedirect
try {
const projectMember = await createInviteMutation({
projectId: projectId,
Expand All @@ -40,35 +49,45 @@ export function useInviteContributor(projectId: number) {

switch (projectMember.code) {
case "already_added":
if (silent) {
return { ok: false, reason: "already_added" }
}
return { [FORM_ERROR]: "User is already a contributor on the project." }

case "restore_possible":
await handleEmailSending(
createReassignmentInvitation(values, currentUser, projectMember.projectmember),
"Reassignment invitation sent to the contributor!",
"Failed to send reassignment email"
"Failed to send reassignment email",
silent
)
break
return { ok: true, code: projectMember.code }

case "invite_sent":
await handleEmailSending(
createNewInvitation(values, currentUser, projectMember.projectmember),
"Contributor invited to the project!",
"Failed to send invitation email"
"Failed to send invitation email",
silent
)
break
return { ok: true, code: projectMember.code }

default:
toast.error("Unexpected response code.")
break
if (!silent) toast.error("Unexpected response code.")
return { ok: false, code: projectMember.code }
}

// Redirect to ContributorsPage after handling the invitation
await router.push(Routes.ContributorsPage({ projectId }))
if (!skipRedirect) {
await router.push(Routes.ContributorsPage({ projectId }))
}
} catch (error: any) {
console.error(error)
if (silent) {
return { ok: false, reason: error?.toString?.() ?? "unknown_error" }
}
return { [FORM_ERROR]: error.toString() }
}
return { ok: true }
}

return handleSubmit
Expand Down
11 changes: 3 additions & 8 deletions src/pages/help/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,9 @@ const HelpPage = () => {

<div className="card bg-base-300 w-1/2 ml-2">
<div className="card-body">
<div className="card-title">STAPLE Presentations</div>
In a workshop? Use our google doc to leave notes.{" "}
<a
className="link-primary"
href="https://docs.google.com/document/d/1y7QxO4BhTygyLUpiOtp735nUnAer_rfzhi0HUcckDVU/edit?usp=sharing"
>
Leave notes here.
</a>{" "}
<div className="card-title">STAPLE Emails</div>
Invitations and notifications come from app@staple.science. The email may go to spam
and may need to be marked as safe to ensure all notification emails are received.
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/pages/projects/[projectId]/contributors/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const ContributorsPage = () => {
className="btn btn-primary mb-2 mt-4"
href={Routes.NewContributorPage({ projectId: projectId! })}
>
Invite Contributor
Invite Contributor(s)
</Link>
<Link
className="btn btn-secondary mx-2 mb-2 mt-4"
Expand Down
2 changes: 1 addition & 1 deletion src/pages/projects/[projectId]/contributors/invites.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const InvitesPagePM = () => {
className="btn btn-primary mb-4 mt-4"
href={Routes.NewContributorPage({ projectId: projectId! })}
>
Invite Contributor
Invite Contributor(s)
</Link>

<Link
Expand Down
69 changes: 62 additions & 7 deletions src/pages/projects/[projectId]/contributors/new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,38 +11,93 @@ import { useInviteContributor } from "src/invites/hooks/useInviteContributor"
import { InformationCircleIcon } from "@heroicons/react/24/outline"
import { Tooltip } from "react-tooltip"
import Card from "src/core/components/Card"
import { toast } from "react-hot-toast"

function NewContributor() {
const projectId = useParam("projectId", "number")
const handleSubmit = useInviteContributor(projectId!)
const router = useRouter()

// Allow multiple emails separated by commas, semicolons, spaces, or new lines
const multiSubmit = async (values: any) => {
const raw = (values?.email ?? "").toString()
const emails = raw
.split(/[\n,;\s]+/)
.map((e) => e.trim())
.filter(Boolean)

if (emails.length === 0) return

const loadingToast = toast.loading(
`Inviting ${emails.length} contributor${emails.length === 1 ? "" : "s"}...`
)

const results: string[] = []
const errors: string[] = []

// Run sequentially to preserve existing server validations and rate limits
for (const email of emails) {
try {
// eslint-disable-next-line no-await-in-loop
const res = await handleSubmit({ ...values, email }, { silent: true, skipRedirect: true })
if (res?.ok) {
results.push(email)
} else {
errors.push(email)
}
} catch {
errors.push(email)
}
}

const succeeded = results.length
const failed = errors.length
const message =
failed === 0
? `✅ Successfully invited ${succeeded} contributor${succeeded === 1 ? "" : "s"}.`
: `✅ Invited ${succeeded} contributor${
succeeded === 1 ? "" : "s"
}, ❌ failed for ${failed}: ${errors.join(", ")}.`

toast.dismiss(loadingToast)
toast.success(message)

// After processing, take the user to the invites page where they can see statuses
await router.push(Routes.InvitesPagePM({ projectId: projectId! }))
}

return (
<main className="flex flex-col mb-2 mt-2 mx-auto w-full max-w-7xl">
<h1 className="flex justify-center items-center text-3xl">
Invite New Contributor
Invite New Contributor(s)
<InformationCircleIcon
className="h-6 w-6 ml-2 text-info stroke-2"
data-tooltip-id="contributors-overview"
/>
<Tooltip
id="contributors-overview"
content="On this page, you can invite a contributor, define their roles, and give them task for classifying."
content="You can invite one person or paste multiple emails separated by commas, spaces, semicolons, or new lines. Define roles, and give them tags for classifying."
className="z-[1099] ourtooltips"
/>
</h1>
<p className="mt-2 mb-2 text-lg">
Enter the email of the contributor you would like to add to the project. They will receive
an email inviting them to join the project. You will not be able to add them to tasks or
teams until they accept their invitation.
Enter the email(s) of the contributor(s) you want to add to the project. You can paste
multiple emails separated by commas, spaces, semicolons, or new lines. They will receive
invitations, and you can add them to tasks or teams after they accept. All contributors
entered together will have the same privilege, roles, and tags. You can add or change these
values after they accept your invite.
</p>
<p className="mt-2 mb-2 text-lg">
Each person will receive an email from app@staple.science. The email may go to spam and may
need to be marked as safe to ensure all notification emails are received.
</p>
<Card title="">
<ContributorForm
projectId={projectId!}
className="flex flex-col"
submitText="Add Contributor"
submitText="Send Invite(s)"
schema={CreateProjectMemberFormSchema}
onSubmit={handleSubmit}
onSubmit={multiSubmit}
isEdit={false}
cancelText="Cancel"
onCancel={() => router.push(Routes.InvitesPagePM({ projectId: projectId! }))}
Expand Down