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:
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<If condition={isFirst}>
|
||||||
<FormLabel>{t('emailLabel')}</FormLabel>
|
<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>
|
||||||
|
<If condition={isFirst}>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans i18nKey={'teams:roleLabel'} />
|
<Trans i18nKey={'teams:roleLabel'} />
|
||||||
</FormLabel>
|
</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,6 +240,7 @@ function InviteMembersForm({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
<If condition={fieldArray.fields.length < MAX_INVITES}>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
data-test={'add-new-invite-button'}
|
data-test={'add-new-invite-button'}
|
||||||
@@ -232,6 +259,7 @@ function InviteMembersForm({
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</If>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type={'submit'} disabled={pending}>
|
<Button type={'submit'} disabled={pending}>
|
||||||
|
|||||||
@@ -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',
|
message: 'Duplicate emails are not allowed',
|
||||||
path: ['invites'],
|
path: ['invitations'],
|
||||||
};
|
},
|
||||||
}
|
);
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user