diff --git a/src/contributors/components/ContributorForm.tsx b/src/contributors/components/ContributorForm.tsx index d50959ba..0d51a9af 100644 --- a/src/contributors/components/ContributorForm.tsx +++ b/src/contributors/components/ContributorForm.tsx @@ -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> extends FormProps { projectId: number @@ -99,8 +98,14 @@ export function ContributorForm>(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 + } } }} > @@ -116,12 +121,12 @@ export function ContributorForm>(props: Contributo opacity={1} /> {!isEdit && ( - )} { + 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, @@ -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 diff --git a/src/pages/help/index.tsx b/src/pages/help/index.tsx index 85150b22..97c9f67f 100644 --- a/src/pages/help/index.tsx +++ b/src/pages/help/index.tsx @@ -74,14 +74,9 @@ const HelpPage = () => {
-
STAPLE Presentations
- In a workshop? Use our google doc to leave notes.{" "} - - Leave notes here. - {" "} +
STAPLE Emails
+ 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.
diff --git a/src/pages/projects/[projectId]/contributors/index.tsx b/src/pages/projects/[projectId]/contributors/index.tsx index 40c0f2c2..0fe51810 100644 --- a/src/pages/projects/[projectId]/contributors/index.tsx +++ b/src/pages/projects/[projectId]/contributors/index.tsx @@ -68,7 +68,7 @@ const ContributorsPage = () => { className="btn btn-primary mb-2 mt-4" href={Routes.NewContributorPage({ projectId: projectId! })} > - Invite Contributor + Invite Contributor(s) { className="btn btn-primary mb-4 mt-4" href={Routes.NewContributorPage({ projectId: projectId! })} > - Invite Contributor + Invite Contributor(s) { + 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 (

- Invite New Contributor + Invite New Contributor(s)

- 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. +

+

+ 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.

router.push(Routes.InvitesPagePM({ projectId: projectId! }))}