diff --git a/packages/lib/server-only/recipient/get-recipient-suggestions.ts b/packages/lib/server-only/recipient/get-recipient-suggestions.ts new file mode 100644 index 000000000..9ffd64c29 --- /dev/null +++ b/packages/lib/server-only/recipient/get-recipient-suggestions.ts @@ -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; +}; diff --git a/packages/trpc/server/recipient-router/find-recipient-suggestions.ts b/packages/trpc/server/recipient-router/find-recipient-suggestions.ts new file mode 100644 index 000000000..34a33b667 --- /dev/null +++ b/packages/trpc/server/recipient-router/find-recipient-suggestions.ts @@ -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, + }; + }); diff --git a/packages/trpc/server/recipient-router/find-recipient-suggestions.types.ts b/packages/trpc/server/recipient-router/find-recipient-suggestions.types.ts new file mode 100644 index 000000000..e6c91a815 --- /dev/null +++ b/packages/trpc/server/recipient-router/find-recipient-suggestions.types.ts @@ -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 +>; diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index dc66306e7..876e21942 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -12,6 +12,7 @@ import { updateTemplateRecipients } from '@documenso/lib/server-only/recipient/u import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema'; import { authenticatedProcedure, procedure, router } from '../trpc'; +import { findRecipientSuggestionsRoute } from './find-recipient-suggestions'; import { ZCompleteDocumentWithTokenMutationSchema, ZCreateDocumentRecipientRequestSchema, @@ -42,6 +43,10 @@ import { } from './schema'; export const recipientRouter = router({ + suggestions: { + find: findRecipientSuggestionsRoute, + }, + /** * @public */ diff --git a/packages/ui/components/recipient/recipient-autocomplete-input.tsx b/packages/ui/components/recipient/recipient-autocomplete-input.tsx new file mode 100644 index 000000000..ec7190e47 --- /dev/null +++ b/packages/ui/components/recipient/recipient-autocomplete-input.tsx @@ -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, keyof RecipientAutoCompleteInputProps>; + +export const RecipientAutoCompleteInput = ({ + value, + placeholder, + disabled, + loading, + onSearchQueryChange, + onSelect, + options = [], + onChange: _onChange, + ...props +}: CombinedProps) => { + const [isOpen, setIsOpen] = useState(false); + + const inputRef = useRef(null); + + const onValueChange = (value: string) => { + setIsOpen(!!value.length); + onSearchQueryChange(value); + }; + + const handleSelectItem = (option: RecipientAutoCompleteOption) => { + setIsOpen(false); + onSelect(option); + }; + + return ( + + + + onValueChange(e.target.value)} + {...props} + /> + + + { + e.preventDefault(); + }} + > + {/* Not using here due to some weird behaviour */} + {options.length === 0 && ( +
+ {loading ? ( + Loading suggestions... + ) : ( + No suggestions found + )} +
+ )} + + {options.length > 0 && ( + + {options.map((option, index) => ( + handleSelectItem(option)} + > + {option.name} ({option.email}) + + ))} + + )} +
+
+
+ ); +}; diff --git a/packages/ui/primitives/command.tsx b/packages/ui/primitives/command.tsx index 983ec474c..2cee1fe0c 100644 --- a/packages/ui/primitives/command.tsx +++ b/packages/ui/primitives/command.tsx @@ -65,6 +65,27 @@ const CommandInput = React.forwardRef< CommandInput.displayName = CommandPrimitive.Input.displayName; +const CommandTextInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ +
+)); + +CommandTextInput.displayName = CommandPrimitive.Input.displayName; + const CommandList = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -147,6 +168,7 @@ export { Command, CommandDialog, CommandInput, + CommandTextInput, CommandList, CommandEmpty, CommandGroup, diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index cf592f448..43caa8fe1 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -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 { 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 { 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 { useSession } from '@documenso/lib/client-only/providers/session'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { nanoid } from '@documenso/lib/universal/id'; 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 { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select'; import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select'; @@ -29,6 +31,8 @@ import { DocumentReadOnlyFields, mapFieldsWithRecipients, } 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 { Checkbox } from '../checkbox'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form'; @@ -75,6 +79,10 @@ export const AddSignersFormPartial = ({ const { remaining } = useLimits(); const { user } = useSession(); + const [recipientSearchQuery, setRecipientSearchQuery] = useState(''); + + const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500); + const initialId = useId(); const $sensorApi = useRef(null); @@ -82,6 +90,17 @@ export const AddSignersFormPartial = ({ const organisation = useCurrentOrganisation(); + const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery( + { + query: debouncedRecipientSearchQuery, + }, + { + enabled: debouncedRecipientSearchQuery.length > 1, + }, + ); + + const recipientSuggestions = recipientSuggestionsData?.results || []; + const defaultRecipients = [ { formId: initialId, @@ -286,10 +305,12 @@ export const AddSignersFormPartial = ({ } }; - const onKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { - onAddSigner(); - } + const handleRecipientAutoCompleteSelect = ( + index: number, + suggestion: RecipientAutoCompleteOption, + ) => { + setValue(`signers.${index}.email`, suggestion.email); + setValue(`signers.${index}.name`, suggestion.name || ''); }; const onDragEnd = useCallback( @@ -679,17 +700,26 @@ export const AddSignersFormPartial = ({ )} - + handleRecipientAutoCompleteSelect(index, suggestion) + } + onSearchQueryChange={(query) => { + console.log('onSearchQueryChange', query); + field.onChange(query); + setRecipientSearchQuery(query); + }} + loading={isLoading} data-testid="signer-email-input" - onKeyDown={onKeyDown} maxLength={254} onBlur={handleAutoSave} /> @@ -720,7 +750,8 @@ export const AddSignersFormPartial = ({ )} - + handleRecipientAutoCompleteSelect(index, suggestion) + } + onSearchQueryChange={(query) => { + field.onChange(query); + setRecipientSearchQuery(query); + }} + loading={isLoading} maxLength={255} onBlur={handleAutoSave} /> diff --git a/packages/ui/primitives/popover.tsx b/packages/ui/primitives/popover.tsx index 906206e4b..f84c3f4b2 100644 --- a/packages/ui/primitives/popover.tsx +++ b/packages/ui/primitives/popover.tsx @@ -8,6 +8,8 @@ const Popover = PopoverPrimitive.Root; const PopoverTrigger = PopoverPrimitive.Trigger; +const PopoverAnchor = PopoverPrimitive.Anchor; + const PopoverContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -91,4 +93,4 @@ const PopoverHover = ({ trigger, children, contentProps, side = 'top' }: Popover ); }; -export { Popover, PopoverTrigger, PopoverContent, PopoverHover }; +export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent, PopoverHover };