mirror of
https://github.com/documenso/documenso.git
synced 2026-07-14 06:47:10 +10:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4212e3e398 | |||
| 9d800978aa |
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user