Compare commits

...

2 Commits

Author SHA1 Message Date
Catalin Pit 4212e3e398 refactor: simplify field dialog component
- Removed unused DialogDescription and FormLabel components for cleaner code.
- Updated DialogTitle to conditionally display field label or a default translation.
- Changed button text from Sign to Enter for better clarity.j
2026-01-08 13:56:47 +02:00
Catalin Pit 9d800978aa feat: update team member creation dialog with invite functionality
- Added support for inviting organisation members when no available members are present.
- Integrated  for inviting new members.
- Improved form validation to handle cases where no members are selected.
- Updated UI to display appropriate messages and alerts based on member availability.
2026-01-08 13:24:03 +02:00
2 changed files with 146 additions and 49 deletions
@@ -12,7 +12,6 @@ import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
@@ -22,7 +21,6 @@ import {
FormControl, FormControl,
FormField, FormField,
FormItem, FormItem,
FormLabel,
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Textarea } from '@documenso/ui/primitives/textarea'; import { Textarea } from '@documenso/ui/primitives/textarea';
@@ -52,13 +50,7 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}> <Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>{fieldMeta?.label || <Trans>Enter Text</Trans>}</DialogTitle>
<Trans>Sign Text Field</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Insert a value into the text field</Trans>
</DialogDescription>
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>
@@ -72,8 +64,6 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
name="text" name="text"
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<FormItem> <FormItem>
{fieldMeta?.label && <FormLabel>{fieldMeta?.label}</FormLabel>}
<FormControl> <FormControl>
<Textarea <Textarea
id="custom-text" id="custom-text"
@@ -89,7 +79,7 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
{fieldMeta?.characterLimit !== undefined && {fieldMeta?.characterLimit !== undefined &&
fieldMeta?.characterLimit > 0 && fieldMeta?.characterLimit > 0 &&
!fieldState.error && ( !fieldState.error && (
<div className="text-muted-foreground text-sm"> <div className="text-sm text-muted-foreground">
<Plural <Plural
value={fieldMeta?.characterLimit - (field.value?.length ?? 0)} value={fieldMeta?.characterLimit - (field.value?.length ?? 0)}
one="# character remaining" one="# character remaining"
@@ -107,7 +97,7 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
</Button> </Button>
<Button type="submit"> <Button type="submit">
<Trans>Sign</Trans> <Trans>Enter</Trans>
</Button> </Button>
</DialogFooter> </DialogFooter>
</fieldset> </fieldset>
@@ -1,10 +1,10 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client'; import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog'; import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { InfoIcon } from 'lucide-react'; import { InfoIcon, UserPlusIcon } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@@ -13,6 +13,7 @@ import { z } from 'zod';
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams'; import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations'; import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@@ -44,6 +45,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
export type TeamMemberCreateDialogProps = { export type TeamMemberCreateDialogProps = {
@@ -64,11 +66,14 @@ type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDialogProps) => { export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDialogProps) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT'); const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT');
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const prevInviteDialogOpenRef = useRef(false);
const { t } = useLingui(); const { t } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const team = useCurrentTeam(); const team = useCurrentTeam();
const utils = trpc.useUtils();
const form = useForm<TAddTeamMembersFormSchema>({ const form = useForm<TAddTeamMembersFormSchema>({
resolver: zodResolver(ZAddTeamMembersFormSchema), resolver: zodResolver(ZAddTeamMembersFormSchema),
@@ -96,7 +101,29 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
); );
}, [organisationMemberQuery, teamMemberQuery]); }, [organisationMemberQuery, teamMemberQuery]);
const hasNoAvailableMembers =
!organisationMemberQuery.isLoading && avaliableOrganisationMembers.length === 0;
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => { const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
if (members.length === 0) {
if (hasNoAvailableMembers) {
setInviteDialogOpen(true);
return;
}
// Don't show error if on SELECT step - the disabled Next button already communicates this
if (step === 'SELECT') {
return;
}
toast({
title: t`No members selected`,
description: t`Please select at least one member to add to the team.`,
variant: 'destructive',
});
return;
}
try { try {
await createTeamMembers({ await createTeamMembers({
teamId: team.id, teamId: team.id,
@@ -123,9 +150,20 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
if (!open) { if (!open) {
form.reset(); form.reset();
setStep('SELECT'); setStep('SELECT');
setInviteDialogOpen(false);
} }
}, [open, form]); }, [open, form]);
// Invalidate queries when invite dialog closes (transitions from true to false) to refresh available members
useEffect(() => {
if (prevInviteDialogOpenRef.current && !inviteDialogOpen) {
void utils.organisation.member.find.invalidate({
organisationId: team.organisationId,
});
}
prevInviteDialogOpenRef.current = inviteDialogOpen;
}, [inviteDialogOpen, utils, team.organisationId]);
return ( return (
<Dialog <Dialog
{...props} {...props}
@@ -134,9 +172,11 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
// Since it would be annoying to redo the whole process. // Since it would be annoying to redo the whole process.
> >
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild> <DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
<Button variant="secondary" onClick={() => setOpen(true)}> {trigger ?? (
<Trans>Add members</Trans> <Button variant="secondary" onClick={() => setOpen(true)}>
</Button> <Trans>Add members</Trans>
</Button>
)}
</DialogTrigger> </DialogTrigger>
<DialogContent hideClose={true} position="center"> <DialogContent hideClose={true} position="center">
@@ -149,7 +189,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
<TooltipTrigger asChild> <TooltipTrigger asChild>
<InfoIcon className="mx-2 h-4 w-4" /> <InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-xs"> <TooltipContent className="z-[99999] max-w-xs text-muted-foreground">
<Trans> <Trans>
To be able to add members to a team, you must first add them to the To be able to add members to a team, you must first add them to the
organisation. For more information, please see the{' '} organisation. For more information, please see the{' '}
@@ -186,7 +226,18 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
.exhaustive()} .exhaustive()}
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}> <form
onSubmit={form.handleSubmit(onFormSubmit)}
onKeyDown={(e) => {
if (e.key === 'Enter' && form.getValues('members').length === 0) {
e.preventDefault();
if (hasNoAvailableMembers) {
setInviteDialogOpen(true);
}
// Don't show toast - the disabled Next button already communicates this
}
}}
>
<fieldset disabled={form.formState.isSubmitting}> <fieldset disabled={form.formState.isSubmitting}>
{step === 'SELECT' && ( {step === 'SELECT' && (
<> <>
@@ -194,46 +245,102 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
control={form.control} control={form.control}
name="members" name="members"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem className="space-y-2">
<FormLabel> <FormLabel>
<Trans>Members</Trans> <Trans>Members</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<MultiSelectCombobox {hasNoAvailableMembers ? (
options={avaliableOrganisationMembers.map((member) => ({ <div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-muted-foreground/25 bg-muted/30 px-6 py-12 text-center">
label: member.name, <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
value: member.id, <UserPlusIcon className="h-6 w-6 text-muted-foreground" />
}))} </div>
loading={organisationMemberQuery.isLoading} <h3 className="mb-2 text-sm font-semibold">
selectedValues={field.value.map( <Trans>No organisation members available</Trans>
(member) => member.organisationMemberId, </h3>
)} <p className="mb-6 max-w-sm text-sm text-muted-foreground">
onChange={(value) => { <Trans>
field.onChange( To add members to this team, you must first add them to the
value.map((organisationMemberId) => ({ organisation.
organisationMemberId, </Trans>
teamRole: </p>
field.value.find( <OrganisationMemberInviteDialog
(member) => open={inviteDialogOpen}
member.organisationMemberId === organisationMemberId, onOpenChange={setInviteDialogOpen}
)?.teamRole || TeamMemberRole.MEMBER, trigger={
})), <Button type="button" variant="default">
); <UserPlusIcon className="mr-2 h-4 w-4" />
}} <Trans>Invite organisation members</Trans>
className="bg-background w-full" </Button>
emptySelectionPlaceholder={t`Select members`} }
/> />
</div>
) : (
<MultiSelectCombobox
options={avaliableOrganisationMembers.map((member) => ({
label: member.name,
value: member.id,
}))}
loading={organisationMemberQuery.isLoading}
selectedValues={field.value.map(
(member) => member.organisationMemberId,
)}
onChange={(value) => {
field.onChange(
value.map((organisationMemberId) => ({
organisationMemberId,
teamRole:
field.value.find(
(member) =>
member.organisationMemberId === organisationMemberId,
)?.teamRole || TeamMemberRole.MEMBER,
})),
);
}}
className="w-full bg-background"
emptySelectionPlaceholder={t`Select members`}
/>
)}
</FormControl> </FormControl>
<FormDescription> {!hasNoAvailableMembers && (
<Trans>Select members to add to this team</Trans> <>
</FormDescription> <FormDescription>
<Trans>Select members to add to this team</Trans>
</FormDescription>
<Alert
variant="neutral"
className="mt-2 flex items-center gap-2 space-y-0"
>
<div>
<UserPlusIcon className="h-5 w-5 text-muted-foreground" />
</div>
<AlertDescription className="mt-0 flex-1">
<Trans>Can't find someone?</Trans>{' '}
<OrganisationMemberInviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
trigger={
<Button
type="button"
variant="link"
className="h-auto p-0 text-sm font-medium text-documenso-700 hover:text-documenso-600"
>
<Trans>Invite them to the organisation first</Trans>
</Button>
}
/>
</AlertDescription>
</Alert>
</>
)}
</FormItem> </FormItem>
)} )}
/> />
<DialogFooter> <DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}> <Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>