chore: merge main

This commit is contained in:
Catalin Documenso
2025-06-24 10:49:08 +03:00
787 changed files with 55308 additions and 42985 deletions

View File

@ -1,51 +1,75 @@
import { forwardRef } from 'react';
import React from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export const DocumentGlobalAuthAccessSelect = forwardRef<HTMLButtonElement, SelectProps>(
(props, ref) => {
const { _ } = useLingui();
export interface DocumentGlobalAuthAccessSelectProps {
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
disabled?: boolean;
placeholder?: string;
}
return (
<Select {...props}>
<SelectTrigger ref={ref} className="bg-background text-muted-foreground">
<SelectValue
data-testid="documentAccessSelectValue"
placeholder={_(msg`No restrictions`)}
/>
</SelectTrigger>
export const DocumentGlobalAuthAccessSelect = ({
value,
defaultValue,
onValueChange,
disabled,
placeholder,
}: DocumentGlobalAuthAccessSelectProps) => {
const { _ } = useLingui();
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>
<Trans>No restrictions</Trans>
</SelectItem>
// Convert auth types to MultiSelect options
const authOptions: Option[] = [
{
value: '-1',
label: _(msg`No restrictions`),
},
...Object.values(DocumentAccessAuth).map((authType) => ({
value: authType,
label: DOCUMENT_AUTH_TYPES[authType].value,
})),
];
{Object.values(DocumentAccessAuth).map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
);
// Convert string array to Option array for MultiSelect
const selectedOptions =
(value
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
// Convert default value to Option array
const defaultOptions =
(defaultValue
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
const handleChange = (options: Option[]) => {
const values = options.map((option) => option.value);
onValueChange?.(values);
};
return (
<MultiSelect
value={selectedOptions}
defaultOptions={defaultOptions}
options={authOptions}
onChange={handleChange}
disabled={disabled}
placeholder={placeholder || _(msg`Select access methods`)}
className="bg-background text-muted-foreground"
hideClearAllButton={false}
data-testid="documentAccessSelectValue"
/>
);
};
DocumentGlobalAuthAccessSelect.displayName = 'DocumentGlobalAuthAccessSelect';
@ -63,7 +87,11 @@ export const DocumentGlobalAuthAccessTooltip = () => (
</h2>
<p>
<Trans>The authentication required for recipients to view the document.</Trans>
<Trans>The authentication methods required for recipients to view the document.</Trans>
</p>
<p className="mt-2">
<Trans>Multiple access methods can be selected.</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">

View File

@ -1,54 +1,75 @@
import { forwardRef } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DocumentActionAuth, DocumentAuth } from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export const DocumentGlobalAuthActionSelect = forwardRef<HTMLButtonElement, SelectProps>(
(props, ref) => {
const { _ } = useLingui();
export interface DocumentGlobalAuthActionSelectProps {
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
disabled?: boolean;
placeholder?: string;
}
return (
<Select {...props}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue
ref={ref}
data-testid="documentActionSelectValue"
placeholder={_(msg`No restrictions`)}
/>
</SelectTrigger>
export const DocumentGlobalAuthActionSelect = ({
value,
defaultValue,
onValueChange,
disabled,
placeholder,
}: DocumentGlobalAuthActionSelectProps) => {
const { _ } = useLingui();
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>
<Trans>No restrictions</Trans>
</SelectItem>
// Convert auth types to MultiSelect options
const authOptions: Option[] = [
{
value: '-1',
label: _(msg`No restrictions`),
},
...Object.values(DocumentActionAuth)
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
.map((authType) => ({
value: authType,
label: DOCUMENT_AUTH_TYPES[authType].value,
})),
];
{Object.values(DocumentActionAuth)
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
);
// Convert string array to Option array for MultiSelect
const selectedOptions =
(value
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
// Convert default value to Option array
const defaultOptions =
(defaultValue
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
const handleChange = (options: Option[]) => {
const values = options.map((option) => option.value);
onValueChange?.(values);
};
return (
<MultiSelect
value={selectedOptions}
defaultOptions={defaultOptions}
options={authOptions}
onChange={handleChange}
disabled={disabled}
placeholder={placeholder || _(msg`Select authentication methods`)}
className="bg-background text-muted-foreground"
hideClearAllButton={false}
data-testid="documentActionSelectValue"
/>
);
};
DocumentGlobalAuthActionSelect.displayName = 'DocumentGlobalAuthActionSelect';
@ -64,20 +85,19 @@ export const DocumentGlobalAuthActionTooltip = () => (
</h2>
<p>
<Trans>The authentication required for recipients to sign the signature field.</Trans>
<Trans>
The authentication methods required for recipients to sign the signature field.
</Trans>
</p>
<p>
<Trans>
This can be overriden by setting the authentication requirements directly on each
recipient in the next step.
These can be overriden by setting the authentication requirements directly on each
recipient in the next step. Multiple methods can be selected.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
{/* <li>
<strong>Require account</strong> - The recipient must be signed in
</li> */}
<li>
<Trans>
<strong>Require passkey</strong> - The recipient must have an account and passkey
@ -90,6 +110,14 @@ export const DocumentGlobalAuthActionTooltip = () => (
their settings
</Trans>
</li>
<li>
<Trans>
<strong>Require password</strong> - The recipient must have an account and password
configured via their settings, the password will be verified during signing
</Trans>
</li>
<li>
<Trans>
<strong>No restrictions</strong> - No authentication required

View File

@ -38,6 +38,14 @@ interface FieldToolTipProps extends VariantProps<typeof tooltipVariants> {
export function FieldToolTip({ children, color, className = '', field }: FieldToolTipProps) {
const coords = useFieldPageCoords(field);
const onTooltipContentClick = () => {
const $fieldEl = document.querySelector<HTMLButtonElement>(`#field-${field.id} > button`);
if ($fieldEl) {
$fieldEl.click();
}
};
return createPortal(
<div
className={cn('pointer-events-none absolute')}
@ -52,7 +60,11 @@ export function FieldToolTip({ children, color, className = '', field }: FieldTo
<Tooltip delayDuration={0} open={!field.inserted || !field.fieldMeta}>
<TooltipTrigger className="absolute inset-0 w-full"></TooltipTrigger>
<TooltipContent className={tooltipVariants({ color, className })} sideOffset={2}>
<TooltipContent
className={tooltipVariants({ color, className })}
sideOffset={2}
onClick={onTooltipContentClick}
>
{children}
<TooltipArrow />
</TooltipContent>

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { type Field, FieldType } from '@prisma/client';
import { createPortal } from 'react-dom';
@ -20,24 +20,37 @@ export function FieldContainerPortal({
children,
className = '',
}: FieldContainerPortalProps) {
const alternativePortalRoot = document.getElementById('document-field-portal-root');
const coords = useFieldPageCoords(field);
const isCheckboxOrRadioField = field.type === 'CHECKBOX' || field.type === 'RADIO';
const style = {
top: `${coords.y}px`,
left: `${coords.x}px`,
...(!isCheckboxOrRadioField && {
height: `${coords.height}px`,
width: `${coords.width}px`,
}),
};
const style = useMemo(() => {
const portalBounds = alternativePortalRoot?.getBoundingClientRect();
const bounds = {
top: `${coords.y}px`,
left: `${coords.x}px`,
...(!isCheckboxOrRadioField && {
height: `${coords.height}px`,
width: `${coords.width}px`,
}),
};
if (portalBounds) {
bounds.top = `${coords.y - portalBounds.top}px`;
bounds.left = `${coords.x - portalBounds.left}px`;
}
return bounds;
}, [coords, isCheckboxOrRadioField]);
return createPortal(
<div className={cn('absolute', className)} style={style}>
{children}
</div>,
document.body,
alternativePortalRoot ?? document.body,
);
}

View File

@ -3,97 +3,124 @@ import React from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { RecipientActionAuth } from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type RecipientActionAuthSelectProps = SelectProps;
export interface RecipientActionAuthSelectProps {
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
disabled?: boolean;
placeholder?: string;
}
export const RecipientActionAuthSelect = (props: RecipientActionAuthSelectProps) => {
export const RecipientActionAuthSelect = ({
value,
defaultValue,
onValueChange,
disabled,
placeholder,
}: RecipientActionAuthSelectProps) => {
const { _ } = useLingui();
// Convert auth types to MultiSelect options
const authOptions: Option[] = [
{
value: '-1',
label: _(msg`Inherit authentication method`),
},
...Object.values(RecipientActionAuth)
.filter((auth) => auth !== RecipientActionAuth.ACCOUNT)
.map((authType) => ({
value: authType,
label: DOCUMENT_AUTH_TYPES[authType].value,
})),
];
// Convert string array to Option array for MultiSelect
const selectedOptions =
(value
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
// Convert default value to Option array
const defaultOptions =
(defaultValue
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
const handleChange = (options: Option[]) => {
const values = options.map((option) => option.value);
onValueChange?.(values);
};
return (
<Select {...props}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue placeholder={_(msg`Inherit authentication method`)} />
<div className="relative">
<MultiSelect
value={selectedOptions}
defaultOptions={defaultOptions}
options={authOptions}
onChange={handleChange}
disabled={disabled}
placeholder={placeholder || _(msg`Select authentication methods`)}
className="bg-background text-muted-foreground"
maxSelected={4} // Allow selecting up to 4 auth methods
hideClearAllButton={false}
/>
<Tooltip>
<TooltipTrigger className="-mr-1 ml-auto">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<Tooltip>
<TooltipTrigger className="absolute right-2 top-1/2 -translate-y-1/2">
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md p-4">
<h2>
<strong>
<Trans>Recipient action authentication</Trans>
</strong>
</h2>
<TooltipContent className="text-foreground max-w-md p-4">
<h2>
<strong>
<Trans>Recipient action authentication</Trans>
</strong>
</h2>
<p>
<Trans>The authentication required for recipients to sign fields</Trans>
</p>
<p>
<Trans>The authentication methods required for recipients to sign fields</Trans>
</p>
<p className="mt-2">
<Trans>This will override any global settings.</Trans>
</p>
<p className="mt-2">
<Trans>
These will override any global settings. Multiple methods can be selected.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Inherit authentication method</strong> - Use the global action signing
authentication method configured in the "General Settings" step
</Trans>
</li>
{/* <li>
<strong>Require account</strong> - The recipient must be
signed in
</li> */}
<li>
<Trans>
<strong>Require passkey</strong> - The recipient must have an account and passkey
configured via their settings
</Trans>
</li>
<li>
<Trans>
<strong>Require 2FA</strong> - The recipient must have an account and 2FA enabled
via their settings
</Trans>
</li>
<li>
<Trans>
<strong>None</strong> - No authentication required
</Trans>
</li>
</ul>
</TooltipContent>
</Tooltip>
</SelectTrigger>
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value="-1">
<Trans>Inherit authentication method</Trans>
</SelectItem>
{Object.values(RecipientActionAuth)
.filter((auth) => auth !== RecipientActionAuth.ACCOUNT)
.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Inherit authentication method</strong> - Use the global action signing
authentication method configured in the "General Settings" step
</Trans>
</li>
<li>
<Trans>
<strong>Require passkey</strong> - The recipient must have an account and passkey
configured via their settings
</Trans>
</li>
<li>
<Trans>
<strong>Require 2FA</strong> - The recipient must have an account and 2FA enabled
via their settings
</Trans>
</li>
<li>
<Trans>
<strong>None</strong> - No authentication required
</Trans>
</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
);
};

View File

@ -24,7 +24,7 @@ export const SigningCard = ({
signingCelebrationImage,
}: SigningCardProps) => {
return (
<div className={cn('relative w-full max-w-xs md:max-w-sm', className)}>
<div className={cn('relative w-full max-w-sm md:max-w-md', className)}>
<SigningCardContent name={name} signature={signature} />
{signingCelebrationImage && (
@ -48,7 +48,7 @@ export const SigningCard3D = ({
const [trackMouse, setTrackMouse] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout>();
const timeoutRef = useRef<number | undefined>();
const cardX = useMotionValue(0);
const cardY = useMotionValue(0);
@ -103,7 +103,7 @@ export const SigningCard3D = ({
clearTimeout(timeoutRef.current);
// Revert the card back to the center position after the mouse stops moving.
timeoutRef.current = setTimeout(() => {
timeoutRef.current = window.setTimeout(() => {
void animate(cardX, 0, { duration: 2, ease: 'backInOut' });
void animate(cardY, 0, { duration: 2, ease: 'backInOut' });
@ -120,12 +120,15 @@ export const SigningCard3D = ({
return () => {
window.removeEventListener('mousemove', onMouseMove);
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
};
}, [onMouseMove]);
return (
<div
className={cn('relative w-full max-w-xs md:max-w-sm', className)}
className={cn('relative w-full max-w-sm md:max-w-md', className)}
style={{ perspective: 800 }}
>
<motion.div

View File

@ -0,0 +1,15 @@
import React, { useEffect } from 'react';
export const useDebounce = <T>(value: T, delay?: number): T => {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
};

View File

@ -39,7 +39,7 @@ const Alert = React.forwardRef<
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant, padding }), className)}
className={cn('space-y-2', alertVariants({ variant, padding }), className)}
{...props}
/>
));

View File

@ -26,6 +26,7 @@ export interface DataTableProps<TData, TValue> {
totalPages?: number;
onPaginationChange?: (_page: number, _perPage: number) => void;
onClearFilters?: () => void;
emptyState?: React.ReactNode;
hasFilters?: boolean;
children?: DataTableChildren<TData>;
skeleton?: {
@ -52,6 +53,7 @@ export function DataTable<TData, TValue>({
onClearFilters,
onPaginationChange,
children,
emptyState,
}: DataTableProps<TData, TValue>) {
const pagination = useMemo<PaginationState>(() => {
if (currentPage !== undefined && perPage !== undefined) {
@ -142,17 +144,21 @@ export function DataTable<TData, TValue>({
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-32 text-center">
<p>
<Trans>No results found</Trans>
</p>
{emptyState ?? (
<>
<p>
<Trans>No results found</Trans>
</p>
{hasFilters && onClearFilters !== undefined && (
<button
onClick={() => onClearFilters()}
className="text-foreground mt-1 text-sm"
>
<Trans>Clear filters</Trans>
</button>
{hasFilters && onClearFilters !== undefined && (
<button
onClick={() => onClearFilters()}
className="text-foreground mt-1 text-sm"
>
<Trans>Clear filters</Trans>
</button>
)}
</>
)}
</TableCell>
</TableRow>

View File

@ -7,6 +7,7 @@ import { AlertTriangle, Plus } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
@ -44,6 +45,8 @@ export const DocumentDropzone = ({
}: DocumentDropzoneProps) => {
const { _ } = useLingui();
const organisation = useCurrentOrganisation();
const { getRootProps, getInputProps } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
@ -158,7 +161,7 @@ export const DocumentDropzone = ({
{disabled && IS_BILLING_ENABLED() && (
<Button className="hover:bg-warning/80 bg-warning mt-4 w-32" asChild>
<Link to="/settings/billing">
<Link to={`/o/${organisation.url}/settings/billing`}>
<Trans>Upgrade</Trans>
</Link>
</Button>

View File

@ -85,7 +85,7 @@ export type AddFieldsFormProps = {
onSubmit: (_data: TAddFieldsFormSchema) => void;
canGoBack?: boolean;
isDocumentPdfLoaded: boolean;
teamId?: number;
teamId: number;
};
export const AddFieldsFormPartial = ({
@ -166,7 +166,6 @@ export const AddFieldsFormPartial = ({
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const [lastActiveField, setLastActiveField] = useState<TAddFieldsFormSchema['fields'][0] | null>(
null,
);
@ -400,35 +399,60 @@ export const AddFieldsFormPartial = ({
);
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
const { duplicate = false } = options ?? {};
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
const { duplicate = false, duplicateAll = false } = options ?? {};
if (lastActiveField) {
event?.preventDefault();
if (!duplicate) {
setFieldClipboard(lastActiveField);
if (duplicate) {
const newField: TAddFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
append(newField);
return;
}
if (duplicateAll) {
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (pageNumber === lastActiveField.pageNumber) {
return;
}
const newField: TAddFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
pageNumber,
};
append(newField);
});
return;
}
const newField: TAddFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
setFieldClipboard(lastActiveField);
append(newField);
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
});
}
},
[append, lastActiveField, selectedSigner?.email, toast],
[append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast],
);
const onFieldPaste = useCallback(
@ -440,6 +464,7 @@ export const AddFieldsFormPartial = ({
append({
...copiedField,
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
pageX: copiedField.pageX + 3,
@ -579,7 +604,6 @@ export const AddFieldsFormPartial = ({
onAdvancedSettings={handleAdvancedSettings}
isDocumentPdfLoaded={isDocumentPdfLoaded}
onSave={handleSavedFieldSettings}
teamId={teamId}
/>
) : (
<>
@ -637,10 +661,13 @@ export const AddFieldsFormPartial = ({
passive={isFieldWithinBounds && !!selectedField}
onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)}
onMouseEnter={() => setLastActiveField(field)}
onMouseLeave={() => setLastActiveField(null)}
onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onAdvancedSettings={() => {
setCurrentField(field);
handleAdvancedSettings();

View File

@ -1,14 +1,20 @@
import { useEffect, useId } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useLingui } from '@lingui/react/macro';
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@prisma/client';
import { Trans, useLingui } from '@lingui/react/macro';
import {
DocumentStatus,
DocumentVisibility,
type Field,
type Recipient,
SendStatus,
TeamMemberRole,
} from '@prisma/client';
import { InfoIcon, Plus, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
@ -71,7 +77,6 @@ export type AddSettingsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
isDocumentEnterprise: boolean;
isDocumentPdfLoaded: boolean;
document: TDocument;
currentTeamMemberRole?: TeamMemberRole;
@ -82,7 +87,6 @@ export const AddSettingsFormPartial = ({
documentFlow,
recipients,
fields,
isDocumentEnterprise,
isDocumentPdfLoaded,
document,
currentTeamMemberRole,
@ -91,6 +95,8 @@ export const AddSettingsFormPartial = ({
const { t } = useLingui();
const initialId = useId();
const organisation = useCurrentOrganisation();
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
@ -111,8 +117,8 @@ export const AddSettingsFormPartial = ({
title: document.title,
externalId: document.externalId || '',
visibility: document.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
globalActionAuth: documentAuthOption?.globalActionAuth || [],
meta: {
timezone:
@ -184,6 +190,12 @@ export const AddSettingsFormPartial = ({
)
.otherwise(() => false);
const onFormSubmit = form.handleSubmit(onSubmit);
const onGoNextClick = () => {
void onFormSubmit().catch(console.error);
};
// We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed.
useEffect(() => {
@ -267,7 +279,11 @@ export const AddSettingsFormPartial = ({
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<Select
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
@ -297,7 +313,11 @@ export const AddSettingsFormPartial = ({
</FormLabel>
<FormControl>
<DocumentGlobalAuthAccessSelect {...field} onValueChange={field.onChange} />
<DocumentGlobalAuthAccessSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -327,7 +347,7 @@ export const AddSettingsFormPartial = ({
/>
)}
{isDocumentEnterprise && (
{organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name="globalActionAuth"
@ -339,7 +359,11 @@ export const AddSettingsFormPartial = ({
</FormLabel>
<FormControl>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
<DocumentGlobalAuthActionSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -423,7 +447,7 @@ export const AddSettingsFormPartial = ({
<FormControl>
<Select
{...field}
value={field.value}
onValueChange={field.onChange}
disabled={documentHasBeenSent}
>
@ -459,7 +483,7 @@ export const AddSettingsFormPartial = ({
<Combobox
className="bg-background"
options={TIME_ZONES}
{...field}
value={field.value}
onChange={(value) => value && field.onChange(value)}
disabled={documentHasBeenSent}
/>
@ -589,7 +613,7 @@ export const AddSettingsFormPartial = ({
disabled={form.formState.isSubmitting}
canGoBack={stepIndex !== 0}
onGoBackClick={previousStep}
onGoNextClick={form.handleSubmit(onSubmit)}
onGoNextClick={onGoNextClick}
/>
</DocumentFlowFormContainerFooter>
</>

View File

@ -17,17 +17,6 @@ import {
ZDocumentMetaTimezoneSchema,
} from '@documenso/trpc/server/document-router/schema';
export const ZMapNegativeOneToUndefinedSchema = z
.string()
.optional()
.transform((val) => {
if (val === '-1') {
return undefined;
}
return val;
});
export const ZAddSettingsFormSchema = z.object({
title: z
.string()
@ -35,12 +24,12 @@ export const ZAddSettingsFormSchema = z.object({
.min(1, { message: msg`Title cannot be empty`.id }),
externalId: z.string().optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentAccessAuthTypesSchema.optional(),
),
globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentActionAuthTypesSchema.optional(),
),
globalAccessAuth: z
.array(z.union([ZDocumentAccessAuthTypesSchema, z.literal('-1')]))
.transform((val) => (val.length === 1 && val[0] === '-1' ? [] : val))
.optional()
.default([]),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema),
meta: z.object({
timezone: ZDocumentMetaTimezoneSchema.optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: ZDocumentMetaDateFormatSchema.optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),

View File

@ -14,6 +14,7 @@ import { useFieldArray, useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
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';
@ -53,7 +54,6 @@ export type AddSignersFormProps = {
fields: Field[];
signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean;
isDocumentEnterprise: boolean;
onSubmit: (_data: TAddSignersFormSchema) => void;
isDocumentPdfLoaded: boolean;
};
@ -64,7 +64,6 @@ export const AddSignersFormPartial = ({
fields,
signingOrder,
allowDictateNextSigner,
isDocumentEnterprise,
onSubmit,
isDocumentPdfLoaded,
}: AddSignersFormProps) => {
@ -78,6 +77,8 @@ export const AddSignersFormPartial = ({
const { currentStep, totalSteps, previousStep } = useStep();
const organisation = useCurrentOrganisation();
const defaultRecipients = [
{
formId: initialId,
@ -85,7 +86,7 @@ export const AddSignersFormPartial = ({
email: '',
role: RecipientRole.SIGNER,
signingOrder: 1,
actionAuth: undefined,
actionAuth: [],
},
];
@ -119,10 +120,14 @@ export const AddSignersFormPartial = ({
const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth;
return (
recipientAuthOptions.accessAuth.length > 0 || recipientAuthOptions.actionAuth.length > 0
);
});
const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth);
const formHasActionAuth = form
.getValues('signers')
.find((signer) => signer.actionAuth.length > 0);
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]);
@ -190,7 +195,7 @@ export const AddSignersFormPartial = ({
name: '',
email: '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
actionAuth: [],
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
});
};
@ -226,7 +231,7 @@ export const AddSignersFormPartial = ({
name: user?.name ?? '',
email: user?.email ?? '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
actionAuth: [],
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
});
}
@ -629,36 +634,37 @@ export const AddSignersFormPartial = ({
)}
/>
{showAdvancedSettings && isDocumentEnterprise && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('col-span-8', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'col-span-10': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.nativeId)
}
/>
</FormControl>
{showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('col-span-8', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'col-span-10': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.nativeId)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormMessage />
</FormItem>
)}
/>
)}
<div className="col-span-2 flex gap-x-2">
<FormField
@ -756,7 +762,7 @@ export const AddSignersFormPartial = ({
</Button>
</div>
{!alwaysShowAdvancedSettings && isDocumentEnterprise && (
{!alwaysShowAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<div className="mt-4 flex flex-row items-center">
<Checkbox
id="showAdvancedRecipientSettings"

View File

@ -4,8 +4,6 @@ import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
export const ZAddSignersFormSchema = z
.object({
signers: z.array(
@ -19,9 +17,7 @@ export const ZAddSignersFormSchema = z
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),

View File

@ -41,7 +41,6 @@ import { RadioFieldAdvancedSettings } from './field-items-advanced-settings/radi
import { TextFieldAdvancedSettings } from './field-items-advanced-settings/text-field';
export type FieldAdvancedSettingsProps = {
teamId?: number;
title: MessageDescriptor;
description: MessageDescriptor;
field: FieldFormType;
@ -311,6 +310,7 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
/>
))
.otherwise(() => null)}
{errors.length > 0 && (
<div className="mt-4">
<ul>
@ -323,6 +323,7 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
</div>
)}
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter className="mt-auto">
<DocumentFlowFormContainerActions
goNextLabel={msg`Save`}

View File

@ -1,9 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { FieldType } from '@prisma/client';
import { CopyPlus, Settings2, Trash } from 'lucide-react';
import { CopyPlus, Settings2, SquareStack, Trash } from 'lucide-react';
import { createPortal } from 'react-dom';
import { Rnd } from 'react-rnd';
import { useSearchParams } from 'react-router';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
@ -29,9 +32,12 @@ export type FieldItemProps = {
onMove?: (_node: HTMLElement) => void;
onRemove?: () => void;
onDuplicate?: () => void;
onDuplicateAllPages?: () => void;
onAdvancedSettings?: () => void;
onFocus?: () => void;
onBlur?: () => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
recipientIndex?: number;
hasErrors?: boolean;
active?: boolean;
@ -55,15 +61,19 @@ export const FieldItem = ({
onMove,
onRemove,
onDuplicate,
onDuplicateAllPages,
onAdvancedSettings,
onFocus,
onBlur,
onAdvancedSettings,
recipientIndex = 0,
hasErrors,
active,
onFieldActivate,
onFieldDeactivate,
}: FieldItemProps) => {
const { _ } = useLingui();
const [searchParams] = useSearchParams();
const [coords, setCoords] = useState({
pageX: 0,
pageY: 0,
@ -75,6 +85,8 @@ export const FieldItem = ({
const signerStyles = useRecipientColors(recipientIndex);
const isDevMode = searchParams.get('devmode') === 'true';
const advancedField = [
'NUMBER',
'RADIO',
@ -227,6 +239,8 @@ export const FieldItem = ({
bounds={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`}
onDragStart={() => onFieldActivate?.()}
onResizeStart={() => onFieldActivate?.()}
onMouseEnter={() => onFocus?.()}
onMouseLeave={() => onBlur?.()}
enableResizing={!fixedSize}
resizeHandleStyles={{
bottom: { bottom: -8, cursor: 'ns-resize' },
@ -297,6 +311,12 @@ export const FieldItem = ({
(field.signerEmail?.charAt(1)?.toUpperCase() ?? '')}
</div>
</div>
{isDevMode && (
<div className="text-muted-foreground absolute -top-6 left-0 right-0 text-center text-[10px]">
{`x: ${field.pageX.toFixed(2)}, y: ${field.pageY.toFixed(2)}`}
</div>
)}
</div>
{!disabled && settingsActive && (
@ -304,6 +324,7 @@ export const FieldItem = ({
<div className="group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
{advancedField && (
<button
title={_(msg`Advanced settings`)}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onAdvancedSettings}
onTouchEnd={onAdvancedSettings}
@ -313,6 +334,7 @@ export const FieldItem = ({
)}
<button
title={_(msg`Duplicate`)}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onDuplicate}
onTouchEnd={onDuplicate}
@ -321,6 +343,16 @@ export const FieldItem = ({
</button>
<button
title={_(msg`Duplicate on all pages`)}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onDuplicateAllPages}
onTouchEnd={onDuplicateAllPages}
>
<SquareStack className="h-3 w-3" />
</button>
<button
title={_(msg`Remove`)}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onRemove}
onTouchEnd={onRemove}

View File

@ -6,8 +6,11 @@ import { Upload } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { Button } from './button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip';
@ -15,6 +18,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tool
export type DocumentDropzoneProps = {
className?: string;
disabled?: boolean;
loading?: boolean;
disabledMessage?: MessageDescriptor;
onDrop?: (_file: File) => void | Promise<void>;
onDropRejected?: () => void | Promise<void>;
@ -24,6 +28,7 @@ export type DocumentDropzoneProps = {
export const DocumentDropzone = ({
className,
loading,
onDrop,
onDropRejected,
disabled,
@ -33,6 +38,12 @@ export const DocumentDropzone = ({
}: DocumentDropzoneProps) => {
const { _ } = useLingui();
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const { getRootProps, getInputProps } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
@ -63,7 +74,13 @@ export const DocumentDropzone = ({
<Tooltip>
<TooltipTrigger asChild>
<Button className="hover:bg-warning/80 bg-warning" asChild>
<Link to="/settings/billing">
<Link
to={
isPersonalLayoutMode
? `/settings/billing`
: `/o/${organisation.url}/settings/billing`
}
>
<Trans>Upgrade</Trans>
</Link>
</Button>
@ -77,10 +94,10 @@ export const DocumentDropzone = ({
}
return (
<Button aria-disabled={disabled} {...getRootProps()} {...props}>
<Button loading={loading} aria-disabled={disabled} {...getRootProps()} {...props}>
<div className="flex items-center gap-2">
<input {...getInputProps()} />
<Upload className="h-4 w-4" />
{!loading && <Upload className="h-4 w-4" />}
{disabled ? _(disabledMessage) : _(heading[type])}
</div>
</Button>

View File

@ -26,6 +26,7 @@ type MultiSelectComboboxProps<T = OptionValue> = {
enableClearAllButton?: boolean;
enableSearch?: boolean;
className?: string;
contentClassName?: string;
loading?: boolean;
inputPlaceholder?: MessageDescriptor;
onChange: (_values: T[]) => void;
@ -46,6 +47,7 @@ export function MultiSelectCombobox<T = OptionValue>({
enableClearAllButton,
enableSearch = true,
className,
contentClassName,
inputPlaceholder,
loading,
onChange,
@ -149,7 +151,7 @@ export function MultiSelectCombobox<T = OptionValue>({
)}
</div>
<PopoverContent className="w-[200px] p-0">
<PopoverContent className={cn('z-[50000000] w-full p-0', contentClassName)}>
<Command>
{enableSearch && <CommandInput placeholder={inputPlaceholder && _(inputPlaceholder)} />}
<CommandEmpty>

View File

@ -0,0 +1,589 @@
'use client';
import * as React from 'react';
import { useEffect } from 'react';
import { Command as CommandPrimitive, useCommandState } from 'cmdk';
import { XIcon } from 'lucide-react';
import { useDebounce } from '../lib/use-debounce';
import { cn } from '../lib/utils';
import { Command, CommandGroup, CommandItem, CommandList } from './command';
export interface Option {
value: string;
label: string;
disable?: boolean;
/** fixed option that can't be removed. */
fixed?: boolean;
/** Group the options by providing key. */
[key: string]: string | boolean | undefined;
}
interface GroupOption {
[key: string]: Option[];
}
interface MultiSelectProps {
value?: Option[];
defaultOptions?: Option[];
/** manually controlled options */
options?: Option[];
placeholder?: string;
/** Loading component. */
loadingIndicator?: React.ReactNode;
/** Empty component. */
emptyIndicator?: React.ReactNode;
/** Debounce time for async search. Only work with `onSearch`. */
delay?: number;
/**
* Only work with `onSearch` prop. Trigger search when `onFocus`.
* For example, when user click on the input, it will trigger the search to get initial options.
**/
triggerSearchOnFocus?: boolean;
/** async search */
onSearch?: (value: string) => Promise<Option[]>;
/**
* sync search. This search will not showing loadingIndicator.
* The rest props are the same as async search.
* i.e.: creatable, groupBy, delay.
**/
onSearchSync?: (value: string) => Option[];
onChange?: (options: Option[]) => void;
/** Limit the maximum number of selected options. */
maxSelected?: number;
/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
onMaxSelected?: (maxLimit: number) => void;
/** Hide the placeholder when there are options selected. */
hidePlaceholderWhenSelected?: boolean;
disabled?: boolean;
/** Group the options base on provided key. */
groupBy?: string;
className?: string;
badgeClassName?: string;
/**
* First item selected is a default behavior by cmdk. That is why the default is true.
* This is a workaround solution by add a dummy item.
*
* @reference: https://github.com/pacocoursey/cmdk/issues/171
*/
selectFirstItem?: boolean;
/** Allow user to create option when there is no option matched. */
creatable?: boolean;
/** Props of `Command` */
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
/** Props of `CommandInput` */
inputProps?: Omit<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
'value' | 'placeholder' | 'disabled'
>;
/** hide the clear all button. */
hideClearAllButton?: boolean;
/** test id for the select value. */
'data-testid'?: string;
}
export interface MultiSelectRef {
selectedValue: Option[];
input: HTMLInputElement;
focus: () => void;
reset: () => void;
}
function transToGroupOption(options: Option[], groupBy?: string) {
if (options.length === 0) {
return {};
}
if (!groupBy) {
return {
'': options,
};
}
const groupOption: GroupOption = {};
options.forEach((option) => {
const key = (option[groupBy] as string) || '';
if (!groupOption[key]) {
groupOption[key] = [];
}
groupOption[key].push(option);
});
return groupOption;
}
function removePickedOption(groupOption: GroupOption, picked: Option[]) {
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
for (const [key, value] of Object.entries(cloneOption)) {
cloneOption[key] = value.filter((val) => !picked.find((p) => p.value === val.value));
}
return cloneOption;
}
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
for (const [, value] of Object.entries(groupOption)) {
if (value.some((option) => targetOption.find((p) => p.value === option.value))) {
return true;
}
}
return false;
}
const CommandEmpty = ({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) => {
const render = useCommandState((state) => state.filtered.count === 0);
if (!render) return null;
return (
<div
className={cn('px-2 py-4 text-center text-sm', className)}
cmdk-empty=""
role="presentation"
{...props}
/>
);
};
CommandEmpty.displayName = 'CommandEmpty';
const MultiSelect = ({
value,
onChange,
placeholder,
defaultOptions: arrayDefaultOptions = [],
options: arrayOptions,
delay,
onSearch,
onSearchSync,
loadingIndicator,
emptyIndicator,
maxSelected = Number.MAX_SAFE_INTEGER,
onMaxSelected,
hidePlaceholderWhenSelected,
disabled,
groupBy,
className,
badgeClassName,
selectFirstItem = true,
creatable = false,
triggerSearchOnFocus = false,
commandProps,
inputProps,
hideClearAllButton = false,
'data-testid': dataTestId,
}: MultiSelectProps) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const [onScrollbar, setOnScrollbar] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const dropdownRef = React.useRef<HTMLDivElement>(null); // Added this
const [selected, setSelected] = React.useState<Option[]>(value || []);
const [options, setOptions] = React.useState<GroupOption>(
transToGroupOption(arrayDefaultOptions, groupBy),
);
const [inputValue, setInputValue] = React.useState('');
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setOpen(false);
inputRef.current.blur();
}
};
const handleUnselect = React.useCallback(
(option: Option) => {
const newOptions = selected.filter((s) => s.value !== option.value);
setSelected(newOptions);
onChange?.(newOptions);
},
[onChange, selected],
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (input) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (input.value === '' && selected.length > 0) {
const lastSelectOption = selected[selected.length - 1];
// If last item is fixed, we should not remove it.
if (!lastSelectOption.fixed) {
handleUnselect(selected[selected.length - 1]);
}
}
}
// This is not a default behavior of the <input /> field
if (e.key === 'Escape') {
input.blur();
}
}
},
[handleUnselect, selected],
);
useEffect(() => {
if (open) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchend', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
};
}, [open]);
useEffect(() => {
if (value) {
setSelected(value);
}
}, [value]);
useEffect(() => {
/** If `onSearch` is provided, do not trigger options updated. */
if (!arrayOptions || onSearch) {
return;
}
const newOption = transToGroupOption(arrayOptions || [], groupBy);
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
setOptions(newOption);
}
}, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]);
useEffect(() => {
/** sync search */
const doSearchSync = () => {
const res = onSearchSync?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
};
// eslint-disable-next-line @typescript-eslint/require-await
const exec = async () => {
if (!onSearchSync || !open) return;
if (triggerSearchOnFocus) {
doSearchSync();
}
if (debouncedSearchTerm) {
doSearchSync();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
useEffect(() => {
/** async search */
const doSearch = async () => {
setIsLoading(true);
const res = await onSearch?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
setIsLoading(false);
};
const exec = async () => {
if (!onSearch || !open) return;
if (triggerSearchOnFocus) {
await doSearch();
}
if (debouncedSearchTerm) {
await doSearch();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
const CreatableItem = () => {
if (!creatable) return undefined;
if (
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
selected.find((s) => s.value === inputValue)
) {
return undefined;
}
const Item = (
<CommandItem
value={inputValue}
className="cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value: string) => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue('');
const newOptions = [...selected, { value, label: value }];
setSelected(newOptions);
onChange?.(newOptions);
}}
>
{`Create "${inputValue}"`}
</CommandItem>
);
// For normal creatable
if (!onSearch && inputValue.length > 0) {
return Item;
}
// For async search creatable. avoid showing creatable item before loading at first.
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
return Item;
}
return undefined;
};
const EmptyItem = React.useCallback(() => {
if (!emptyIndicator) return undefined;
// For async search that showing emptyIndicator
if (onSearch && !creatable && Object.keys(options).length === 0) {
return (
<CommandItem value="-" disabled>
{emptyIndicator}
</CommandItem>
);
}
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
}, [creatable, emptyIndicator, onSearch, options]);
const selectables = React.useMemo<GroupOption>(
() => removePickedOption(options, selected),
[options, selected],
);
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
const commandFilter = React.useCallback(() => {
if (commandProps?.filter) {
return commandProps.filter;
}
if (creatable) {
return (value: string, search: string) => {
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
};
}
// Using default filter in `cmdk`. We don&lsquo;t have to provide it.
return undefined;
}, [creatable, commandProps?.filter]);
return (
<Command
ref={dropdownRef}
{...commandProps}
onKeyDown={(e) => {
handleKeyDown(e);
commandProps?.onKeyDown?.(e);
}}
className={cn('h-auto overflow-visible bg-transparent', commandProps?.className)}
shouldFilter={
commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch
} // When onSearch is provided, we don&lsquo;t want to filter the options. You can still override it.
filter={commandFilter()}
data-testid={dataTestId}
>
<div
className={cn(
'border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 relative min-h-[38px] rounded-md border text-sm outline-none transition-[color,box-shadow] focus-within:ring-[3px]',
{
'p-1': selected.length !== 0,
'cursor-text': !disabled && selected.length !== 0,
},
!hideClearAllButton && 'pe-9',
className,
)}
onClick={() => {
if (disabled) return;
inputRef?.current?.focus();
}}
>
<div className="flex flex-wrap gap-1">
{selected.map((option) => {
return (
<div
key={option.value}
className={cn(
'animate-fadeIn bg-background text-secondary-foreground hover:bg-background data-fixed:pe-2 relative inline-flex h-7 cursor-default items-center rounded-md border pe-7 pl-2 ps-2 text-xs font-medium transition-all disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
badgeClassName,
)}
data-fixed={option.fixed}
data-disabled={disabled || undefined}
>
{option.label}
<button
className="text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute -inset-y-px -end-px flex size-7 items-center justify-center rounded-e-md border border-transparent p-0 outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUnselect(option);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(option)}
aria-label="Remove"
>
<XIcon size={14} aria-hidden="true" />
</button>
</div>
);
})}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
{...inputProps}
ref={inputRef}
value={inputValue}
disabled={disabled}
onValueChange={(value) => {
setInputValue(value);
inputProps?.onValueChange?.(value);
}}
onBlur={(event) => {
if (!onScrollbar) {
setOpen(false);
}
inputProps?.onBlur?.(event);
}}
onFocus={(event) => {
setOpen(true);
if (triggerSearchOnFocus) {
void onSearch?.(debouncedSearchTerm);
}
inputProps?.onFocus?.(event);
}}
placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder}
className={cn(
'placeholder:text-muted-foreground/70 flex-1 bg-transparent outline-none disabled:cursor-not-allowed',
{
'w-full': hidePlaceholderWhenSelected,
'px-3 py-2': selected.length === 0,
'ml-1': selected.length !== 0,
},
inputProps?.className,
)}
/>
<button
type="button"
onClick={() => {
setSelected(selected.filter((s) => s.fixed));
onChange?.(selected.filter((s) => s.fixed));
}}
className={cn(
'text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute end-0 top-0 flex size-9 items-center justify-center rounded-md border border-transparent outline-none transition-[color,box-shadow] focus-visible:ring-[3px]',
(hideClearAllButton ||
disabled ||
selected.length < 1 ||
selected.filter((s) => s.fixed).length === selected.length) &&
'hidden',
)}
aria-label="Clear all"
>
<XIcon size={16} aria-hidden="true" />
</button>
</div>
</div>
<div className="relative">
<div
className={cn(
'border-input absolute top-2 z-10 w-full overflow-hidden rounded-md border',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
!open && 'hidden',
)}
data-state={open ? 'open' : 'closed'}
>
{open && (
<CommandList
className="bg-popover text-popover-foreground shadow-lg outline-none"
onMouseLeave={() => {
setOnScrollbar(false);
}}
onMouseEnter={() => {
setOnScrollbar(true);
}}
onMouseUp={() => {
inputRef?.current?.focus();
}}
>
{isLoading ? (
<>{loadingIndicator}</>
) : (
<>
{EmptyItem()}
{CreatableItem()}
{!selectFirstItem && <CommandItem value="-" className="hidden" />}
{Object.entries(selectables).map(([key, dropdowns]) => (
<CommandGroup key={key} heading={key} className="h-full overflow-auto">
<>
{dropdowns.map((option) => {
return (
<CommandItem
key={option.value}
value={option.value}
disabled={option.disable}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue('');
const newOptions = [...selected, option];
setSelected(newOptions);
onChange?.(newOptions);
}}
className={cn(
'cursor-pointer',
option.disable &&
'pointer-events-none cursor-not-allowed opacity-50',
)}
>
{option.label}
</CommandItem>
);
})}
</>
</CommandGroup>
))}
</>
)}
</CommandList>
)}
</div>
</div>
</Command>
);
};
MultiSelect.displayName = 'MultiSelect';
export { MultiSelect };

View File

@ -0,0 +1,55 @@
import * as React from 'react';
import { cva } from 'class-variance-authority';
import { Loader } from 'lucide-react';
import { cn } from '../lib/utils';
const spinnerVariants = cva('text-muted-foreground animate-spin', {
variants: {
size: {
default: 'h-6 w-6',
sm: 'h-4 w-4',
lg: 'h-8 w-8',
},
},
defaultVariants: {
size: 'default',
},
});
type SpinnerSize = 'default' | 'sm' | 'lg';
export interface SpinnerProps extends Omit<React.ComponentPropsWithoutRef<typeof Loader>, 'size'> {
size?: SpinnerSize;
}
const Spinner = React.forwardRef<SVGSVGElement, SpinnerProps>(
({ className, size = 'default', ...props }, ref) => {
return <Loader ref={ref} className={cn(spinnerVariants({ size }), className)} {...props} />;
},
);
Spinner.displayName = 'Spinner';
export interface SpinnerBoxProps extends React.HTMLAttributes<HTMLDivElement> {
spinnerProps?: SpinnerProps;
}
const SpinnerBox = React.forwardRef<HTMLDivElement, SpinnerBoxProps>(
({ className, spinnerProps, ...props }, ref) => {
return (
<div
ref={ref}
className={cn('flex items-center justify-center rounded-lg', className)}
{...props}
>
<Spinner {...spinnerProps} />
</div>
);
},
);
SpinnerBox.displayName = 'SpinnerBox';
export { Spinner, SpinnerBox, spinnerVariants };

View File

@ -71,7 +71,7 @@ export type AddTemplateFieldsFormProps = {
recipients: Recipient[];
fields: Field[];
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
teamId?: number;
teamId: number;
};
export const AddTemplateFieldsFormPartial = ({
@ -139,44 +139,64 @@ export const AddTemplateFieldsFormPartial = ({
);
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
const { duplicate = false } = options ?? {};
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
const { duplicate = false, duplicateAll = false } = options ?? {};
if (lastActiveField) {
event?.preventDefault();
if (!duplicate) {
setFieldClipboard(lastActiveField);
if (duplicate) {
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
signerId: selectedSigner?.id ?? lastActiveField.signerId,
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
append(newField);
return;
}
if (duplicateAll) {
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (pageNumber === lastActiveField.pageNumber) {
return;
}
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
signerId: selectedSigner?.id ?? lastActiveField.signerId,
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
pageNumber,
};
append(newField);
});
return;
}
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
signerId: selectedSigner?.id ?? lastActiveField.signerId,
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
setFieldClipboard(lastActiveField);
append(newField);
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
});
}
},
[
append,
lastActiveField,
selectedSigner?.email,
selectedSigner?.id,
selectedSigner?.token,
toast,
],
[append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast],
);
const onFieldPaste = useCallback(
@ -189,6 +209,7 @@ export const AddTemplateFieldsFormPartial = ({
append({
...copiedField,
formId: nanoid(12),
nativeId: undefined,
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
signerId: selectedSigner?.id ?? copiedField.signerId,
signerToken: selectedSigner?.token ?? copiedField.signerToken,
@ -490,7 +511,6 @@ export const AddTemplateFieldsFormPartial = ({
fields={localFields}
onAdvancedSettings={handleAdvancedSettings}
onSave={handleSavedFieldSettings}
teamId={teamId}
/>
) : (
<>
@ -543,6 +563,7 @@ export const AddTemplateFieldsFormPartial = ({
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onAdvancedSettings={() => {
setCurrentField(field);
handleAdvancedSettings();

View File

@ -12,6 +12,7 @@ import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
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';
@ -52,14 +53,12 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean;
templateDirectLink?: TemplateDirectLink | null;
isEnterprise: boolean;
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
isDocumentPdfLoaded: boolean;
};
export const AddTemplatePlaceholderRecipientsFormPartial = ({
documentFlow,
isEnterprise,
recipients,
templateDirectLink,
fields,
@ -74,6 +73,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const { _ } = useLingui();
const { user } = useSession();
const organisation = useCurrentOrganisation();
const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(() =>
recipients.length > 1 ? recipients.length + 1 : 2,
);
@ -86,7 +87,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
{
formId: initialId,
role: RecipientRole.SIGNER,
actionAuth: undefined,
actionAuth: [],
...generateRecipientPlaceholder(1),
signingOrder: 1,
},
@ -136,10 +137,14 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth;
return (
recipientAuthOptions.accessAuth.length > 0 || recipientAuthOptions.actionAuth.length > 0
);
});
const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth);
const formHasActionAuth = form
.getValues('signers')
.find((signer) => signer.actionAuth.length > 0);
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]);
@ -179,6 +184,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
email: user.email ?? '',
role: RecipientRole.SIGNER,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
actionAuth: [],
});
};
@ -188,6 +194,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
role: RecipientRole.SIGNER,
...generateRecipientPlaceholder(placeholderRecipientCount),
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
actionAuth: [],
});
setPlaceholderRecipientCount((count) => count + 1);
@ -643,29 +650,30 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
)}
/>
{showAdvancedSettings && isEnterprise && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('col-span-8', {
'col-span-10': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={isSubmitting}
/>
</FormControl>
{showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('col-span-8', {
'col-span-10': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormMessage />
</FormItem>
)}
/>
)}
<div className="col-span-2 flex gap-x-2">
<FormField
@ -767,7 +775,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
</Button>
</div>
{!alwaysShowAdvancedSettings && isEnterprise && (
{!alwaysShowAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<div className="mt-4 flex flex-row items-center">
<Checkbox
id="showAdvancedRecipientSettings"

View File

@ -3,8 +3,6 @@ import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
export const ZAddTemplatePlacholderRecipientsFormSchema = z
.object({
signers: z.array(
@ -15,9 +13,7 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),

View File

@ -9,6 +9,7 @@ import { InfoIcon, Plus, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import {
DOCUMENT_DISTRIBUTION_METHODS,
@ -79,7 +80,6 @@ export type AddTemplateSettingsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
isEnterprise: boolean;
isDocumentPdfLoaded: boolean;
template: TTemplate;
currentTeamMemberRole?: TeamMemberRole;
@ -90,7 +90,6 @@ export const AddTemplateSettingsFormPartial = ({
documentFlow,
recipients,
fields,
isEnterprise,
isDocumentPdfLoaded,
template,
currentTeamMemberRole,
@ -98,6 +97,8 @@ export const AddTemplateSettingsFormPartial = ({
}: AddTemplateSettingsFormProps) => {
const { t, i18n } = useLingui();
const organisation = useCurrentOrganisation();
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
});
@ -108,8 +109,8 @@ export const AddTemplateSettingsFormPartial = ({
title: template.title,
externalId: template.externalId || undefined,
visibility: template.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
globalActionAuth: documentAuthOption?.globalActionAuth || [],
meta: {
subject: template.templateMeta?.subject ?? '',
message: template.templateMeta?.message ?? '',
@ -269,7 +270,11 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<DocumentGlobalAuthAccessSelect {...field} onValueChange={field.onChange} />
<DocumentGlobalAuthAccessSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -398,7 +403,7 @@ export const AddTemplateSettingsFormPartial = ({
)}
/>
{isEnterprise && (
{organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name="globalActionAuth"
@ -410,7 +415,11 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel>
<FormControl>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
<DocumentGlobalAuthActionSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}

View File

@ -19,18 +19,16 @@ import {
ZDocumentMetaTimezoneSchema,
} from '@documenso/trpc/server/document-router/schema';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
export const ZAddTemplateSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),
externalId: z.string().optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentAccessAuthTypesSchema.optional(),
),
globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentActionAuthTypesSchema.optional(),
),
globalAccessAuth: z
.array(z.union([ZDocumentAccessAuthTypesSchema, z.literal('-1')]))
.transform((val) => (val.length === 1 && val[0] === '-1' ? [] : val))
.optional()
.default([]),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]),
meta: z.object({
subject: z.string(),
message: z.string(),