Files
myeasycms-v2/packages/features/team-accounts/src/components/members/invite-members-dialog-container.tsx
Giancarlo Buomprisco 4bc8448a1d Unify workspace dropdowns; Update layouts (#458)
Unified Account and Workspace drop-downs; Layout updates, now header lives within the PageBody component; Sidebars now use floating variant
2026-03-11 14:45:42 +08:00

349 lines
10 KiB
TypeScript

'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Mail, Plus, X } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useAction } from 'next-safe-action/hooks';
import { useFieldArray, useForm } from 'react-hook-form';
import { Alert, AlertDescription } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@kit/ui/input-group';
import { toast } from '@kit/ui/sonner';
import { Spinner } from '@kit/ui/spinner';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@kit/ui/tooltip';
import { Trans } from '@kit/ui/trans';
import { InviteMembersSchema } from '../../schema/invite-members.schema';
import { createInvitationsAction } from '../../server/actions/team-invitations-server-actions';
import { MembershipRoleSelector } from './membership-role-selector';
import { RolesDataProvider } from './roles-data-provider';
type InviteModel = ReturnType<typeof createEmptyInviteModel>;
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({
accountSlug,
userRoleHierarchy,
children,
}: React.PropsWithChildren<{
accountSlug: string;
userRoleHierarchy: number;
}>) {
const [isOpen, setIsOpen] = useState(false);
const t = useTranslations('teams');
const { execute, isPending } = useAction(createInvitationsAction, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success(t('inviteMembersSuccessMessage'));
} else {
toast.error(t('inviteMembersErrorMessage'));
}
setIsOpen(false);
},
onError: () => {
toast.error(t('inviteMembersErrorMessage'));
setIsOpen(false);
},
});
// Evaluate policies when dialog is open
const {
data: policiesResult,
isLoading: isLoadingPolicies,
error: policiesError,
} = useFetchInvitationsPolicies({ accountSlug, isOpen });
return (
<Dialog open={isOpen} onOpenChange={setIsOpen} disablePointerDismissal>
<DialogTrigger render={children as React.ReactElement} />
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'teams.inviteMembersHeading'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'teams.inviteMembersDescription'} />
</DialogDescription>
</DialogHeader>
<If condition={isLoadingPolicies}>
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
<Spinner className="h-6 w-6" />
<span className="text-muted-foreground text-sm">
<Trans i18nKey="teams.checkingPolicies" />
</span>
</div>
</If>
<If condition={policiesError}>
<Alert variant="destructive">
<AlertDescription>
<Trans
i18nKey="teams.policyCheckError"
values={{ error: policiesError?.message }}
/>
</AlertDescription>
</Alert>
</If>
<If condition={policiesResult && !policiesResult.allowed}>
<Alert variant="destructive">
<AlertDescription>
<Trans
i18nKey={policiesResult?.reasons[0]}
defaults={policiesResult?.reasons[0]}
/>
</AlertDescription>
</Alert>
</If>
<If condition={policiesResult?.allowed}>
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
{(roles) => (
<InviteMembersForm
pending={isPending}
roles={roles}
onSubmit={(data) => {
execute({
accountSlug,
invitations: data.invitations,
});
}}
/>
)}
</RolesDataProvider>
</If>
</DialogContent>
</Dialog>
);
}
function InviteMembersForm({
onSubmit,
roles,
pending,
}: {
onSubmit: (data: { invitations: InviteModel[] }) => void;
pending: boolean;
roles: string[];
}) {
const t = useTranslations('teams');
const form = useForm({
resolver: zodResolver(InviteMembersSchema),
shouldUseNativeValidation: true,
reValidateMode: 'onSubmit',
defaultValues: {
invitations: [createEmptyInviteModel()],
},
});
const fieldArray = useFieldArray({
control: form.control,
name: 'invitations',
});
return (
<Form {...form}>
<form
className={'flex flex-col space-y-8'}
data-test={'invite-members-form'}
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="flex flex-col gap-y-2.5">
{fieldArray.fields.map((field, index) => {
const emailInputName = `invitations.${index}.email` as const;
const roleInputName = `invitations.${index}.role` as const;
return (
<div data-test={'invite-member-form-item'} key={field.id}>
<div className={'flex items-end gap-x-2'}>
<InputGroup className={'bg-background w-full'}>
<InputGroupAddon align="inline-start">
<Mail className="h-4 w-4" />
</InputGroupAddon>
<FormField
name={emailInputName}
render={({ field }) => {
return (
<FormItem className="w-full">
<FormControl>
<InputGroupInput
data-test={'invite-email-input'}
placeholder={t('emailPlaceholder')}
type="email"
required
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</InputGroup>
<FormField
name={roleInputName}
render={({ field }) => {
return (
<FormItem>
<FormControl>
<MembershipRoleSelector
triggerClassName={'m-0 bg-muted'}
roles={roles}
value={field.value}
onChange={(role) => {
if (role) {
form.setValue(field.name, role);
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<div className={'flex items-end justify-end'}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
variant={'ghost'}
size={'icon'}
type={'button'}
disabled={fieldArray.fields.length <= 1}
data-test={'remove-invite-button'}
aria-label={t('removeInviteButtonLabel')}
onClick={() => {
fieldArray.remove(index);
form.clearErrors(emailInputName);
}}
>
<X className={'h-4'} />
</Button>
}
/>
<TooltipContent>
{t('removeInviteButtonLabel')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
);
})}
<If condition={fieldArray.fields.length < MAX_INVITES}>
<div>
<Button
data-test={'add-new-invite-button'}
type={'button'}
variant={'link'}
size={'sm'}
disabled={pending}
onClick={() => {
fieldArray.append(createEmptyInviteModel());
}}
>
<Plus className={'mr-1 h-3'} />
<span>
<Trans i18nKey={'teams.addAnotherMemberButtonLabel'} />
</span>
</Button>
</div>
</If>
</div>
<Button type={'submit'} disabled={pending}>
<Trans
i18nKey={
pending
? 'teams.invitingMembers'
: 'teams.inviteMembersButtonLabel'
}
/>
</Button>
</form>
</Form>
);
}
function createEmptyInviteModel() {
return { email: '', role: 'member' as Role };
}
function useFetchInvitationsPolicies({
accountSlug,
isOpen,
}: {
accountSlug: string;
isOpen: boolean;
}) {
return useQuery({
queryKey: ['invitation-policies', accountSlug],
queryFn: async () => {
const response = await fetch(`./members/policies`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
},
enabled: isOpen,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
}