mirror of
https://github.com/documenso/documenso.git
synced 2025-11-16 17:51:49 +10:00
feat: expiry links
This commit is contained in:
131
packages/ui/primitives/date-time-picker.tsx
Normal file
131
packages/ui/primitives/date-time-picker.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './button';
|
||||
import { Calendar } from './calendar';
|
||||
import { Input } from './input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
|
||||
export interface DateTimePickerProps {
|
||||
value?: Date;
|
||||
onChange?: (date: Date | undefined) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
minDate?: Date;
|
||||
}
|
||||
|
||||
export const DateTimePicker = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
className,
|
||||
minDate = new Date(),
|
||||
}: DateTimePickerProps) => {
|
||||
const { _ } = useLingui();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const handleDateSelect = (selectedDate: Date | undefined) => {
|
||||
if (!selectedDate) {
|
||||
onChange?.(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
const existingTime = DateTime.fromJSDate(value);
|
||||
const newDateTime = DateTime.fromJSDate(selectedDate).set({
|
||||
hour: existingTime.hour,
|
||||
minute: existingTime.minute,
|
||||
});
|
||||
onChange?.(newDateTime.toJSDate());
|
||||
} else {
|
||||
const now = DateTime.now();
|
||||
const newDateTime = DateTime.fromJSDate(selectedDate).set({
|
||||
hour: now.hour,
|
||||
minute: now.minute,
|
||||
});
|
||||
onChange?.(newDateTime.toJSDate());
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleTimeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const timeValue = event.target.value;
|
||||
if (!timeValue || !value) return;
|
||||
|
||||
const [hours, minutes] = timeValue.split(':').map(Number);
|
||||
const newDateTime = DateTime.fromJSDate(value).set({
|
||||
hour: hours,
|
||||
minute: minutes,
|
||||
});
|
||||
|
||||
onChange?.(newDateTime.toJSDate());
|
||||
};
|
||||
|
||||
const formatDateTime = (date: Date) => {
|
||||
return DateTime.fromJSDate(date).toFormat('MMM dd, yyyy');
|
||||
};
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return DateTime.fromJSDate(date).toFormat('HH:mm');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex gap-2', className)}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'w-[200px] justify-start text-left font-normal',
|
||||
!value && 'text-muted-foreground',
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{value ? formatDateTime(value) : <span>{placeholder || _(msg`Pick a date`)}</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value}
|
||||
onSelect={handleDateSelect}
|
||||
disabled={
|
||||
disabled
|
||||
? true
|
||||
: (date) => {
|
||||
return date < minDate;
|
||||
}
|
||||
}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{value && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
<Trans>at</Trans>
|
||||
</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={formatTime(value)}
|
||||
onChange={handleTimeChange}
|
||||
disabled={disabled}
|
||||
className="w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -11,7 +11,7 @@ import {
|
||||
TeamMemberRole,
|
||||
} from '@prisma/client';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
@ -56,6 +56,7 @@ import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combo
|
||||
|
||||
import { DocumentSignatureSettingsTooltip } from '../../components/document/document-signature-settings-tooltip';
|
||||
import { Combobox } from '../combobox';
|
||||
import { ExpirySettingsPicker } from '../expiry-settings-picker';
|
||||
import { Input } from '../input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
|
||||
import { useStep } from '../stepper';
|
||||
@ -71,6 +72,18 @@ import {
|
||||
} from './document-flow-root';
|
||||
import type { DocumentFlowStep } from './types';
|
||||
|
||||
const isExpiryUnit = (
|
||||
value: unknown,
|
||||
): value is 'minutes' | 'hours' | 'days' | 'weeks' | 'months' => {
|
||||
return (
|
||||
value === 'minutes' ||
|
||||
value === 'hours' ||
|
||||
value === 'days' ||
|
||||
value === 'weeks' ||
|
||||
value === 'months'
|
||||
);
|
||||
};
|
||||
|
||||
export type AddSettingsFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
recipients: Recipient[];
|
||||
@ -98,6 +111,9 @@ export const AddSettingsFormPartial = ({
|
||||
documentAuth: document.authOptions,
|
||||
});
|
||||
|
||||
const documentExpiryUnit = document.documentMeta?.expiryUnit;
|
||||
const initialExpiryUnit = isExpiryUnit(documentExpiryUnit) ? documentExpiryUnit : undefined;
|
||||
|
||||
const form = useForm<TAddSettingsFormSchema>({
|
||||
resolver: zodResolver(ZAddSettingsFormSchema),
|
||||
defaultValues: {
|
||||
@ -117,6 +133,8 @@ export const AddSettingsFormPartial = ({
|
||||
redirectUrl: document.documentMeta?.redirectUrl ?? '',
|
||||
language: document.documentMeta?.language ?? 'en',
|
||||
signatureTypes: extractTeamSignatureSettings(document.documentMeta),
|
||||
expiryAmount: document.documentMeta?.expiryAmount ?? undefined,
|
||||
expiryUnit: initialExpiryUnit,
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -127,6 +145,9 @@ export const AddSettingsFormPartial = ({
|
||||
(recipient) => recipient.sendStatus === SendStatus.SENT,
|
||||
);
|
||||
|
||||
const expiryAmount = useWatch({ control: form.control, name: 'meta.expiryAmount' });
|
||||
const expiryUnit = useWatch({ control: form.control, name: 'meta.expiryUnit' });
|
||||
|
||||
const canUpdateVisibility = match(currentTeamMemberRole)
|
||||
.with(TeamMemberRole.ADMIN, () => true)
|
||||
.with(
|
||||
@ -469,6 +490,33 @@ export const AddSettingsFormPartial = ({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<FormLabel className="mb-4 block">
|
||||
<Trans>Link Expiry</Trans>
|
||||
</FormLabel>
|
||||
<ExpirySettingsPicker
|
||||
value={{
|
||||
expiryDuration:
|
||||
expiryAmount && expiryUnit
|
||||
? {
|
||||
amount: expiryAmount,
|
||||
unit: expiryUnit,
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
disabled={documentHasBeenSent}
|
||||
onValueChange={(value) => {
|
||||
if (value.expiryDuration) {
|
||||
form.setValue('meta.expiryAmount', value.expiryDuration.amount);
|
||||
form.setValue('meta.expiryUnit', value.expiryDuration.unit);
|
||||
} else {
|
||||
form.setValue('meta.expiryAmount', undefined);
|
||||
form.setValue('meta.expiryUnit', undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@ -46,6 +46,8 @@ export const ZAddSettingsFormSchema = z.object({
|
||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
||||
message: msg`At least one signature type must be enabled`.id,
|
||||
}),
|
||||
expiryAmount: z.number().int().min(1).optional(),
|
||||
expiryUnit: z.enum(['minutes', 'hours', 'days', 'weeks', 'months']).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
106
packages/ui/primitives/duration-selector.tsx
Normal file
106
packages/ui/primitives/duration-selector.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { Input } from './input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
|
||||
|
||||
export type TimeUnit = 'minutes' | 'hours' | 'days' | 'weeks' | 'months';
|
||||
|
||||
export interface DurationValue {
|
||||
amount: number;
|
||||
unit: TimeUnit;
|
||||
}
|
||||
|
||||
export interface DurationSelectorProps {
|
||||
value?: DurationValue;
|
||||
onChange?: (value: DurationValue) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
minAmount?: number;
|
||||
maxAmount?: number;
|
||||
}
|
||||
|
||||
const TIME_UNITS: Array<{ value: TimeUnit; label: string; labelPlural: string }> = [
|
||||
{ value: 'minutes', label: 'Minute', labelPlural: 'Minutes' },
|
||||
{ value: 'hours', label: 'Hour', labelPlural: 'Hours' },
|
||||
{ value: 'days', label: 'Day', labelPlural: 'Days' },
|
||||
{ value: 'weeks', label: 'Week', labelPlural: 'Weeks' },
|
||||
{ value: 'months', label: 'Month', labelPlural: 'Months' },
|
||||
];
|
||||
|
||||
export const DurationSelector = ({
|
||||
value = { amount: 1, unit: 'days' },
|
||||
onChange,
|
||||
disabled = false,
|
||||
className,
|
||||
minAmount = 1,
|
||||
maxAmount = 365,
|
||||
}: DurationSelectorProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const amount = parseInt(event.target.value, 10);
|
||||
if (!isNaN(amount) && amount >= minAmount && amount <= maxAmount) {
|
||||
onChange?.({ ...value, amount });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnitChange = (unit: TimeUnit) => {
|
||||
onChange?.({ ...value, unit });
|
||||
};
|
||||
|
||||
const getUnitLabel = (unit: TimeUnit, amount: number) => {
|
||||
const unitConfig = TIME_UNITS.find((u) => u.value === unit);
|
||||
if (!unitConfig) return unit;
|
||||
|
||||
return amount === 1 ? unitConfig.label : unitConfig.labelPlural;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
<Input
|
||||
type="number"
|
||||
value={value.amount}
|
||||
onChange={handleAmountChange}
|
||||
disabled={disabled}
|
||||
min={minAmount}
|
||||
max={maxAmount}
|
||||
className="w-20"
|
||||
/>
|
||||
<Select value={value.unit} onValueChange={handleUnitChange} disabled={disabled}>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue>{getUnitLabel(value.unit, value.amount)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_UNITS.map((unit) => (
|
||||
<SelectItem key={unit.value} value={unit.value}>
|
||||
{getUnitLabel(unit.value, value.amount)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const calculateExpiryDate = (duration: DurationValue, fromDate: Date = new Date()): Date => {
|
||||
switch (duration.unit) {
|
||||
case 'minutes':
|
||||
return DateTime.fromJSDate(fromDate).plus({ minutes: duration.amount }).toJSDate();
|
||||
case 'hours':
|
||||
return DateTime.fromJSDate(fromDate).plus({ hours: duration.amount }).toJSDate();
|
||||
case 'days':
|
||||
return DateTime.fromJSDate(fromDate).plus({ days: duration.amount }).toJSDate();
|
||||
case 'weeks':
|
||||
return DateTime.fromJSDate(fromDate).plus({ weeks: duration.amount }).toJSDate();
|
||||
case 'months':
|
||||
return DateTime.fromJSDate(fromDate).plus({ months: duration.amount }).toJSDate();
|
||||
default:
|
||||
return DateTime.fromJSDate(fromDate).plus({ days: duration.amount }).toJSDate();
|
||||
}
|
||||
};
|
||||
121
packages/ui/primitives/expiry-settings-picker.tsx
Normal file
121
packages/ui/primitives/expiry-settings-picker.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { DurationSelector } from './duration-selector';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from './form/form';
|
||||
|
||||
const ZExpirySettingsSchema = z.object({
|
||||
expiryDuration: z
|
||||
.object({
|
||||
amount: z.number().int().min(1),
|
||||
unit: z.enum(['minutes', 'hours', 'days', 'weeks', 'months']),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type ExpirySettings = z.infer<typeof ZExpirySettingsSchema>;
|
||||
|
||||
export interface ExpirySettingsPickerProps {
|
||||
className?: string;
|
||||
defaultValues?: Partial<ExpirySettings>;
|
||||
disabled?: boolean;
|
||||
onValueChange?: (value: ExpirySettings) => void;
|
||||
value?: ExpirySettings;
|
||||
}
|
||||
|
||||
export const ExpirySettingsPicker = ({
|
||||
className,
|
||||
defaultValues = {
|
||||
expiryDuration: undefined,
|
||||
},
|
||||
disabled = false,
|
||||
onValueChange,
|
||||
value,
|
||||
}: ExpirySettingsPickerProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const form = useForm<ExpirySettings>({
|
||||
resolver: zodResolver(ZExpirySettingsSchema),
|
||||
defaultValues,
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const { watch, setValue, getValues } = form;
|
||||
const expiryDuration = watch('expiryDuration');
|
||||
|
||||
// Call onValueChange when form values change
|
||||
React.useEffect(() => {
|
||||
const subscription = watch((value) => {
|
||||
if (onValueChange) {
|
||||
onValueChange(value as ExpirySettings);
|
||||
}
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [watch, onValueChange]);
|
||||
|
||||
// Keep internal form state in sync when a controlled value is provided
|
||||
React.useEffect(() => {
|
||||
if (value === undefined) return;
|
||||
|
||||
const current = getValues('expiryDuration');
|
||||
const next = value.expiryDuration;
|
||||
|
||||
const amountsDiffer = (current?.amount ?? null) !== (next?.amount ?? null);
|
||||
const unitsDiffer = (current?.unit ?? null) !== (next?.unit ?? null);
|
||||
|
||||
if (amountsDiffer || unitsDiffer) {
|
||||
setValue('expiryDuration', next, {
|
||||
shouldDirty: false,
|
||||
shouldTouch: false,
|
||||
shouldValidate: false,
|
||||
});
|
||||
}
|
||||
}, [value, getValues, setValue]);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<Form {...form}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expiryDuration"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Link Expiry</Trans>
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
<Trans>Set an expiry duration for signing links (leave empty to disable)</Trans>
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<DurationSelector
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={disabled}
|
||||
minAmount={1}
|
||||
maxAmount={365}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user