feat: implement recipients autosuggestions (#1923)

This commit is contained in:
Catalin Pit
2025-09-09 13:57:26 +03:00
committed by GitHub
parent 93a3809f6a
commit 7c8e93b53e
8 changed files with 349 additions and 11 deletions

View File

@ -0,0 +1,108 @@
import { Prisma } from '@prisma/client';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
export type GetRecipientSuggestionsOptions = {
userId: number;
teamId?: number;
query: string;
};
export const getRecipientSuggestions = async ({
userId,
teamId,
query,
}: GetRecipientSuggestionsOptions) => {
const trimmedQuery = query.trim();
const nameEmailFilter = trimmedQuery
? {
OR: [
{
name: {
contains: trimmedQuery,
mode: Prisma.QueryMode.insensitive,
},
},
{
email: {
contains: trimmedQuery,
mode: Prisma.QueryMode.insensitive,
},
},
],
}
: {};
const recipients = await prisma.recipient.findMany({
where: {
document: {
team: buildTeamWhereQuery({ teamId, userId }),
},
...nameEmailFilter,
},
select: {
name: true,
email: true,
document: {
select: {
createdAt: true,
},
},
},
distinct: ['email'],
orderBy: {
document: {
createdAt: 'desc',
},
},
take: 5,
});
if (teamId) {
const teamMembers = await prisma.organisationMember.findMany({
where: {
user: {
...nameEmailFilter,
NOT: { id: userId },
},
organisationGroupMembers: {
some: {
group: {
teamGroups: {
some: { teamId },
},
},
},
},
},
include: {
user: {
select: {
email: true,
name: true,
},
},
},
take: 5,
});
const uniqueTeamMember = teamMembers.find(
(member) => !recipients.some((r) => r.email === member.user.email),
);
if (uniqueTeamMember) {
const teamMemberSuggestion = {
email: uniqueTeamMember.user.email,
name: uniqueTeamMember.user.name,
};
const allSuggestions = [...recipients.slice(0, 4), teamMemberSuggestion];
return allSuggestions;
}
}
return recipients;
};

View File

@ -0,0 +1,34 @@
import { getRecipientSuggestions } from '@documenso/lib/server-only/recipient/get-recipient-suggestions';
import { authenticatedProcedure } from '../trpc';
import {
ZGetRecipientSuggestionsRequestSchema,
ZGetRecipientSuggestionsResponseSchema,
} from './find-recipient-suggestions.types';
/**
* @private
*/
export const findRecipientSuggestionsRoute = authenticatedProcedure
.input(ZGetRecipientSuggestionsRequestSchema)
.output(ZGetRecipientSuggestionsResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { query } = input;
ctx.logger.info({
input: {
query,
},
});
const suggestions = await getRecipientSuggestions({
userId: user.id,
teamId,
query,
});
return {
results: suggestions,
};
});

View File

@ -0,0 +1,22 @@
import { z } from 'zod';
export const ZGetRecipientSuggestionsRequestSchema = z.object({
query: z.string().default(''),
});
export const ZGetRecipientSuggestionsResponseSchema = z.object({
results: z.array(
z.object({
name: z.string().nullable(),
email: z.string().email(),
}),
),
});
export type TGetRecipientSuggestionsRequestSchema = z.infer<
typeof ZGetRecipientSuggestionsRequestSchema
>;
export type TGetRecipientSuggestionsResponseSchema = z.infer<
typeof ZGetRecipientSuggestionsResponseSchema
>;

View File

@ -12,6 +12,7 @@ import { updateTemplateRecipients } from '@documenso/lib/server-only/recipient/u
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema'; import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
import { authenticatedProcedure, procedure, router } from '../trpc'; import { authenticatedProcedure, procedure, router } from '../trpc';
import { findRecipientSuggestionsRoute } from './find-recipient-suggestions';
import { import {
ZCompleteDocumentWithTokenMutationSchema, ZCompleteDocumentWithTokenMutationSchema,
ZCreateDocumentRecipientRequestSchema, ZCreateDocumentRecipientRequestSchema,
@ -42,6 +43,10 @@ import {
} from './schema'; } from './schema';
export const recipientRouter = router({ export const recipientRouter = router({
suggestions: {
find: findRecipientSuggestionsRoute,
},
/** /**
* @public * @public
*/ */

View File

@ -0,0 +1,106 @@
import React, { useRef, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { PopoverAnchor } from '@radix-ui/react-popover';
import { Popover, PopoverContent } from '@documenso/ui/primitives/popover';
import { Command, CommandGroup, CommandItem } from '../../primitives/command';
import { Input } from '../../primitives/input';
export type RecipientAutoCompleteOption = {
email: string;
name: string | null;
};
type RecipientAutoCompleteInputProps = {
type: 'email' | 'text';
value: string;
placeholder?: string;
disabled?: boolean;
loading?: boolean;
options: RecipientAutoCompleteOption[];
onSelect: (option: RecipientAutoCompleteOption) => void;
onSearchQueryChange: (query: string) => void;
};
type CombinedProps = RecipientAutoCompleteInputProps &
Omit<React.InputHTMLAttributes<HTMLInputElement>, keyof RecipientAutoCompleteInputProps>;
export const RecipientAutoCompleteInput = ({
value,
placeholder,
disabled,
loading,
onSearchQueryChange,
onSelect,
options = [],
onChange: _onChange,
...props
}: CombinedProps) => {
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const onValueChange = (value: string) => {
setIsOpen(!!value.length);
onSearchQueryChange(value);
};
const handleSelectItem = (option: RecipientAutoCompleteOption) => {
setIsOpen(false);
onSelect(option);
};
return (
<Command>
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverAnchor asChild>
<Input
ref={inputRef}
className="w-full"
placeholder={placeholder}
value={value}
disabled={disabled}
onChange={(e) => onValueChange(e.target.value)}
{...props}
/>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-full p-0"
onOpenAutoFocus={(e) => {
e.preventDefault();
}}
>
{/* Not using <CommandEmpty /> here due to some weird behaviour */}
{options.length === 0 && (
<div className="px-2 py-1.5 text-sm">
{loading ? (
<Trans>Loading suggestions...</Trans>
) : (
<Trans>No suggestions found</Trans>
)}
</div>
)}
{options.length > 0 && (
<CommandGroup className="max-h-[250px] overflow-y-auto">
{options.map((option, index) => (
<CommandItem
key={`${index}-${option.email}`}
value={`${option.email}`}
className="cursor-pointer"
onSelect={() => handleSelectItem(option)}
>
{option.name} ({option.email})
</CommandItem>
))}
</CommandGroup>
)}
</PopoverContent>
</Popover>
</Command>
);
};

View File

@ -65,6 +65,27 @@ const CommandInput = React.forwardRef<
CommandInput.displayName = CommandPrimitive.Input.displayName; CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandTextInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div cmdk-input-wrapper="">
<CommandPrimitive.Input
ref={ref}
className={cn(
'bg-background border-input ring-offset-background placeholder:text-muted-foreground/40 focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
{
'ring-2 !ring-red-500 transition-all': props['aria-invalid'],
},
)}
{...props}
/>
</div>
));
CommandTextInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef< const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>, React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
@ -147,6 +168,7 @@ export {
Command, Command,
CommandDialog, CommandDialog,
CommandInput, CommandInput,
CommandTextInput,
CommandList, CommandList,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,

View File

@ -1,4 +1,4 @@
import React, { useCallback, useId, useMemo, useRef, useState } from 'react'; import { useCallback, useId, useMemo, useRef, useState } from 'react';
import type { DropResult, SensorAPI } from '@hello-pangea/dnd'; import type { DropResult, SensorAPI } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
@ -15,11 +15,13 @@ import { prop, sortBy } from 'remeda';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave'; import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients'; import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select'; import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select'; import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
@ -29,6 +31,8 @@ import {
DocumentReadOnlyFields, DocumentReadOnlyFields,
mapFieldsWithRecipients, mapFieldsWithRecipients,
} from '../../components/document/document-read-only-fields'; } from '../../components/document/document-read-only-fields';
import type { RecipientAutoCompleteOption } from '../../components/recipient/recipient-autocomplete-input';
import { RecipientAutoCompleteInput } from '../../components/recipient/recipient-autocomplete-input';
import { Button } from '../button'; import { Button } from '../button';
import { Checkbox } from '../checkbox'; import { Checkbox } from '../checkbox';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
@ -75,6 +79,10 @@ export const AddSignersFormPartial = ({
const { remaining } = useLimits(); const { remaining } = useLimits();
const { user } = useSession(); const { user } = useSession();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const initialId = useId(); const initialId = useId();
const $sensorApi = useRef<SensorAPI | null>(null); const $sensorApi = useRef<SensorAPI | null>(null);
@ -82,6 +90,17 @@ export const AddSignersFormPartial = ({
const organisation = useCurrentOrganisation(); const organisation = useCurrentOrganisation();
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
{
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1,
},
);
const recipientSuggestions = recipientSuggestionsData?.results || [];
const defaultRecipients = [ const defaultRecipients = [
{ {
formId: initialId, formId: initialId,
@ -286,10 +305,12 @@ export const AddSignersFormPartial = ({
} }
}; };
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { const handleRecipientAutoCompleteSelect = (
if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { index: number,
onAddSigner(); suggestion: RecipientAutoCompleteOption,
} ) => {
setValue(`signers.${index}.email`, suggestion.email);
setValue(`signers.${index}.name`, suggestion.name || '');
}; };
const onDragEnd = useCallback( const onDragEnd = useCallback(
@ -679,17 +700,26 @@ export const AddSignersFormPartial = ({
)} )}
<FormControl> <FormControl>
<Input <RecipientAutoCompleteInput
type="email" type="email"
placeholder={_(msg`Email`)} placeholder={_(msg`Email`)}
{...field} value={field.value}
disabled={ disabled={
snapshot.isDragging || snapshot.isDragging ||
isSubmitting || isSubmitting ||
!canRecipientBeModified(signer.nativeId) !canRecipientBeModified(signer.nativeId)
} }
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
console.log('onSearchQueryChange', query);
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input" data-testid="signer-email-input"
onKeyDown={onKeyDown}
maxLength={254} maxLength={254}
onBlur={handleAutoSave} onBlur={handleAutoSave}
/> />
@ -720,7 +750,8 @@ export const AddSignersFormPartial = ({
)} )}
<FormControl> <FormControl>
<Input <RecipientAutoCompleteInput
type="text"
placeholder={_(msg`Name`)} placeholder={_(msg`Name`)}
{...field} {...field}
disabled={ disabled={
@ -728,7 +759,15 @@ export const AddSignersFormPartial = ({
isSubmitting || isSubmitting ||
!canRecipientBeModified(signer.nativeId) !canRecipientBeModified(signer.nativeId)
} }
onKeyDown={onKeyDown} options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255} maxLength={255}
onBlur={handleAutoSave} onBlur={handleAutoSave}
/> />

View File

@ -8,6 +8,8 @@ const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef< const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>, React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
@ -91,4 +93,4 @@ const PopoverHover = ({ trigger, children, contentProps, side = 'top' }: Popover
); );
}; };
export { Popover, PopoverTrigger, PopoverContent, PopoverHover }; export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent, PopoverHover };