Add team member invitation success and error messages

The code now includes success and error messages for team member invitation completion. It validates entered email addresses for duplicates and sets a limit on the number of invitations that can be sent at once to avoid server spam. Also, visual changes have been made to the form - label placement, form message, button types, etc.
This commit is contained in:
giancarlo
2024-04-13 20:24:23 +08:00
parent f0bc6959e1
commit 78b6ae1ab0
3 changed files with 69 additions and 44 deletions

View File

@@ -98,6 +98,8 @@
"membersPageHeading": "Members", "membersPageHeading": "Members",
"inviteMembersButton": "Invite Members", "inviteMembersButton": "Invite Members",
"invitingMembers": "Inviting members...", "invitingMembers": "Inviting members...",
"inviteMembersSuccessMessage": "Members invited successfully",
"inviteMembersErrorMessage": "Sorry, members could not be invited. Please try again.",
"pendingInvitesHeading": "Pending Invites", "pendingInvitesHeading": "Pending Invites",
"pendingInvitesDescription": " Here you can manage the pending invitations to your team.", "pendingInvitesDescription": " Here you can manage the pending invitations to your team.",
"noPendingInvites": "No pending invites found", "noPendingInvites": "No pending invites found",

View File

@@ -6,6 +6,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Plus, X } from 'lucide-react'; import { Plus, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import {
@@ -22,7 +23,9 @@ import {
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { import {
Tooltip, Tooltip,
@@ -41,6 +44,12 @@ type InviteModel = ReturnType<typeof createEmptyInviteModel>;
type Role = string; type Role = string;
/**
* The maximum number of invites that can be sent at once.
* Useful to avoid spamming the server with too large payloads
*/
const MAX_INVITES = 5;
export function InviteMembersDialogContainer({ export function InviteMembersDialogContainer({
accountSlug, accountSlug,
accountId, accountId,
@@ -53,6 +62,7 @@ export function InviteMembersDialogContainer({
}>) { }>) {
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation('teams');
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen} modal> <Dialog open={isOpen} onOpenChange={setIsOpen} modal>
@@ -79,11 +89,17 @@ export function InviteMembersDialogContainer({
roles={roles} roles={roles}
onSubmit={(data) => { onSubmit={(data) => {
startTransition(async () => { startTransition(async () => {
await createInvitationsAction({ const promise = createInvitationsAction({
accountSlug, accountSlug,
invitations: data.invitations, invitations: data.invitations,
}); });
toast.promise(() => promise, {
loading: t('invitingMembers'),
success: t('inviteMembersSuccessMessage'),
error: t('inviteMembersErrorMessage'),
});
setIsOpen(false); setIsOpen(false);
}); });
}} }}
@@ -129,6 +145,8 @@ function InviteMembersForm({
> >
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
{fieldArray.fields.map((field, index) => { {fieldArray.fields.map((field, index) => {
const isFirst = index === 0;
const emailInputName = `invitations.${index}.email` as const; const emailInputName = `invitations.${index}.email` as const;
const roleInputName = `invitations.${index}.role` as const; const roleInputName = `invitations.${index}.role` as const;
@@ -141,7 +159,9 @@ function InviteMembersForm({
render={({ field }) => { render={({ field }) => {
return ( return (
<FormItem> <FormItem>
<FormLabel>{t('emailLabel')}</FormLabel> <If condition={isFirst}>
<FormLabel>{t('emailLabel')}</FormLabel>
</If>
<FormControl> <FormControl>
<Input <Input
@@ -152,6 +172,8 @@ function InviteMembersForm({
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage />
</FormItem> </FormItem>
); );
}} }}
@@ -164,9 +186,11 @@ function InviteMembersForm({
render={({ field }) => { render={({ field }) => {
return ( return (
<FormItem> <FormItem>
<FormLabel> <If condition={isFirst}>
<Trans i18nKey={'teams:roleLabel'} /> <FormLabel>
</FormLabel> <Trans i18nKey={'teams:roleLabel'} />
</FormLabel>
</If>
<FormControl> <FormControl>
<MembershipRoleSelector <MembershipRoleSelector
@@ -177,18 +201,20 @@ function InviteMembersForm({
}} }}
/> />
</FormControl> </FormControl>
<FormMessage />
</FormItem> </FormItem>
); );
}} }}
/> />
</div> </div>
<div className={'flex w-[60px] justify-end'}> <div className={'flex w-[40px] justify-end'}>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant={'outline'} variant={'ghost'}
size={'icon'} size={'icon'}
type={'button'} type={'button'}
disabled={fieldArray.fields.length <= 1} disabled={fieldArray.fields.length <= 1}
@@ -214,24 +240,26 @@ function InviteMembersForm({
); );
})} })}
<div> <If condition={fieldArray.fields.length < MAX_INVITES}>
<Button <div>
data-test={'add-new-invite-button'} <Button
type={'button'} data-test={'add-new-invite-button'}
variant={'link'} type={'button'}
size={'sm'} variant={'link'}
disabled={pending} size={'sm'}
onClick={() => { disabled={pending}
fieldArray.append(createEmptyInviteModel()); onClick={() => {
}} fieldArray.append(createEmptyInviteModel());
> }}
<Plus className={'mr-1 h-3'} /> >
<Plus className={'mr-1 h-3'} />
<span> <span>
<Trans i18nKey={'teams:addAnotherMemberButtonLabel'} /> <Trans i18nKey={'teams:addAnotherMemberButtonLabel'} />
</span> </span>
</Button> </Button>
</div> </div>
</If>
</div> </div>
<Button type={'submit'} disabled={pending}> <Button type={'submit'} disabled={pending}>

View File

@@ -2,30 +2,25 @@ import { z } from 'zod';
const InviteSchema = z.object({ const InviteSchema = z.object({
email: z.string().email(), email: z.string().email(),
role: z.string().min(1), role: z.string().min(1).max(100),
}); });
export const InviteMembersSchema = z export const InviteMembersSchema = z
.object({ .object({
invitations: InviteSchema.array(), invitations: InviteSchema.array().min(1).max(5),
}) })
.refine((data) => { .refine(
if (!data.invitations.length) { (data) => {
return { const emails = data.invitations.map((member) =>
message: 'At least one invite is required', member.email.toLowerCase(),
path: ['invites'], );
};
}
const emails = data.invitations.map((member) => member.email.toLowerCase()); const uniqueEmails = new Set(emails);
const uniqueEmails = new Set(emails);
if (emails.length !== uniqueEmails.size) { return emails.length === uniqueEmails.size;
return { },
message: 'Duplicate emails are not allowed', {
path: ['invites'], message: 'Duplicate emails are not allowed',
}; path: ['invitations'],
} },
);
return true;
});