mirror of
https://github.com/documenso/documenso.git
synced 2025-11-09 20:12:31 +10:00
feat: implement recipients autosuggestions (#1923)
This commit is contained in:
108
packages/lib/server-only/recipient/get-recipient-suggestions.ts
Normal file
108
packages/lib/server-only/recipient/get-recipient-suggestions.ts
Normal 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;
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@ -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
|
||||||
|
>;
|
||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user