From 4b72e7d5466caefbb5c272c3e21b07e28a799d0a Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Mon, 20 Jul 2026 10:02:39 +0300 Subject: [PATCH] feat: add document preferences reset dialog (#3039) --- .../document-preferences-reset-dialog.tsx | 141 ++++++++++++++++++ .../forms/document-preferences-form.tsx | 82 +++++++--- .../components/forms/form-sticky-save-bar.tsx | 11 +- .../o.$orgUrl.settings.document.tsx | 2 +- .../t.$teamUrl+/settings.document.tsx | 2 +- 5 files changed, 212 insertions(+), 26 deletions(-) create mode 100644 apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx diff --git a/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx b/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx new file mode 100644 index 000000000..9adebb36e --- /dev/null +++ b/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx @@ -0,0 +1,141 @@ +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Trans } from '@lingui/react/macro'; +import { useState } from 'react'; + +export type DocumentPreferencesResetDialogProps = { + isSubmitting: boolean; + onReset: () => Promise; + showAiFeatures?: boolean; + showDocumentVisibility?: boolean; + showIncludeSenderDetails?: boolean; +}; + +export const DocumentPreferencesResetDialog = ({ + isSubmitting, + onReset, + showAiFeatures = false, + showDocumentVisibility = false, + showIncludeSenderDetails = false, +}: DocumentPreferencesResetDialogProps) => { + const [open, setOpen] = useState(false); + const [isResetting, setIsResetting] = useState(false); + + const isLoading = isSubmitting || isResetting; + + const handleResetToDefaults = async () => { + setIsResetting(true); + + try { + await onReset(); + setOpen(false); + } catch { + // The submit handler surfaces its own error toast. Keep the dialog open + // so the user can retry. + } finally { + setIsResetting(false); + } + }; + + return ( + !isLoading && setOpen(value)}> + + + + + + + + Reset document preferences + + + + + This will reset all document preferences to their default values and save the changes immediately. + + + + + + +

+ Once confirmed, the following will be reset: +

+ +
    + {showDocumentVisibility && ( +
  • + Default document visibility +
  • + )} +
  • + Default document language +
  • +
  • + Default date format +
  • +
  • + Default time zone +
  • +
  • + Default signature settings +
  • + {showIncludeSenderDetails && ( +
  • + Send on behalf of team +
  • + )} +
  • + Include the signing certificate in the document +
  • +
  • + Include the audit logs in the document +
  • +
  • + Default recipients +
  • +
  • + Delegate document ownership +
  • +
  • + Default envelope expiration +
  • +
  • + Default signing reminders +
  • + {showAiFeatures && ( +
  • + AI features +
  • + )} +
+
+
+ + + + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/forms/document-preferences-form.tsx b/apps/remix/app/components/forms/document-preferences-form.tsx index f1a0b6d8a..cdf39eb51 100644 --- a/apps/remix/app/components/forms/document-preferences-form.tsx +++ b/apps/remix/app/components/forms/document-preferences-form.tsx @@ -11,10 +11,10 @@ import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } fr import { TIME_ZONES } from '@documenso/lib/constants/time-zones'; import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients'; import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients'; -import { type TDocumentMetaDateFormat, ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta'; -import { isPersonalLayout } from '@documenso/lib/utils/organisations'; +import { type TDocumentMetaDateFormat, ZDocumentMetaDateFormatSchema } from '@documenso/lib/types/document-meta'; +import { generateDefaultOrganisationSettings, isPersonalLayout } from '@documenso/lib/utils/organisations'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; -import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams'; +import { extractTeamSignatureSettings, generateDefaultTeamSettings } from '@documenso/lib/utils/teams'; import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip'; import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker'; import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker'; @@ -37,11 +37,11 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg, t } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { TeamGlobalSettings } from '@prisma/client'; -import { DocumentVisibility, OrganisationType, type RecipientRole } from '@prisma/client'; +import { DocumentVisibility, OrganisationType, type RecipientRole, type TeamGlobalSettings } from '@prisma/client'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog'; import { useOptionalCurrentTeam } from '~/providers/team'; import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox'; @@ -93,6 +93,26 @@ export type DocumentPreferencesFormProps = { onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise; }; +const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPreferencesFormSchema => { + const parsedDocumentDateFormat = ZDocumentMetaDateFormatSchema.safeParse(settings.documentDateFormat); + + return { + documentVisibility: settings.documentVisibility, + documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null, + documentTimezone: settings.documentTimezone, + documentDateFormat: parsedDocumentDateFormat.success ? parsedDocumentDateFormat.data : null, + includeSenderDetails: settings.includeSenderDetails, + includeSigningCertificate: settings.includeSigningCertificate, + includeAuditLog: settings.includeAuditLog, + signatureTypes: extractTeamSignatureSettings({ ...settings }), + defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null, + delegateDocumentOwnership: settings.delegateDocumentOwnership, + aiFeaturesEnabled: settings.aiFeaturesEnabled, + envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null, + reminderSettings: settings.reminderSettings ?? null, + }; +}; + export const DocumentPreferencesForm = ({ settings, onFormSubmit, @@ -113,7 +133,7 @@ export const DocumentPreferencesForm = ({ documentVisibility: z.nativeEnum(DocumentVisibility).nullable(), documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(), documentTimezone: z.string().nullable(), - documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(), + documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(), includeSenderDetails: z.boolean().nullable(), includeSigningCertificate: z.boolean().nullable(), includeAuditLog: z.boolean().nullable(), @@ -127,26 +147,33 @@ export const DocumentPreferencesForm = ({ reminderSettings: ZEnvelopeReminderSettings.nullable(), }); + const defaultValues = getDocumentPreferencesFormValues(settings); + const defaultSettings = canInherit ? generateDefaultTeamSettings() : generateDefaultOrganisationSettings(); + const baseResetValues = getDocumentPreferencesFormValues(defaultSettings); + const resetValues = { + ...baseResetValues, + aiFeaturesEnabled: isAiFeaturesConfigured ? baseResetValues.aiFeaturesEnabled : defaultValues.aiFeaturesEnabled, + }; + const form = useForm({ - defaultValues: { - documentVisibility: settings.documentVisibility, - documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null, - documentTimezone: settings.documentTimezone, - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null, - includeSenderDetails: settings.includeSenderDetails, - includeSigningCertificate: settings.includeSigningCertificate, - includeAuditLog: settings.includeAuditLog, - signatureTypes: extractTeamSignatureSettings({ ...settings }), - defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null, - delegateDocumentOwnership: settings.delegateDocumentOwnership, - aiFeaturesEnabled: settings.aiFeaturesEnabled, - envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null, - reminderSettings: settings.reminderSettings ?? null, - }, + defaultValues, resolver: zodResolver(ZDocumentPreferencesFormSchema), }); + // Parse both sides through the schema so we compare canonical representations + const parsedCurrentValues = ZDocumentPreferencesFormSchema.safeParse(defaultValues); + const parsedResetValues = ZDocumentPreferencesFormSchema.safeParse(resetValues); + + const isResetToDefaultsVisible = + !parsedCurrentValues.success || + !parsedResetValues.success || + JSON.stringify(parsedCurrentValues.data) !== JSON.stringify(parsedResetValues.data); + + const handleResetToDefaults = async () => { + await onFormSubmit(resetValues); + form.reset(resetValues); + }; + const handleFormSubmit = form.handleSubmit(async (data) => { try { await onFormSubmit(data); @@ -772,6 +799,17 @@ export const DocumentPreferencesForm = ({ isDirty={form.formState.isDirty} isSubmitting={form.formState.isSubmitting} onReset={() => form.reset()} + resetToDefaults={ + isResetToDefaultsVisible ? ( + + ) : undefined + } /> diff --git a/apps/remix/app/components/forms/form-sticky-save-bar.tsx b/apps/remix/app/components/forms/form-sticky-save-bar.tsx index 1d37f9482..addc155e7 100644 --- a/apps/remix/app/components/forms/form-sticky-save-bar.tsx +++ b/apps/remix/app/components/forms/form-sticky-save-bar.tsx @@ -3,12 +3,17 @@ import { Button } from '@documenso/ui/primitives/button'; import { Trans, useLingui } from '@lingui/react/macro'; import { AnimatePresence, motion } from 'framer-motion'; import { AlertTriangleIcon } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; export type FormStickySaveBarProps = { isDirty: boolean; isSubmitting: boolean; onReset: () => void; + /** + * Slot for a "reset to defaults" action, rendered before the Undo button. Hidden while + * the bar is floating so it never appears in the unsaved-changes island. + */ + resetToDefaults?: ReactNode; }; /** @@ -24,7 +29,7 @@ export type FormStickySaveBarProps = { * shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle * the pill chrome. */ -export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormStickySaveBarProps) => { +export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefaults }: FormStickySaveBarProps) => { const { t } = useLingui(); const sentinelRef = useRef(null); @@ -100,6 +105,8 @@ export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormSticky
+ {!isFloating && resetToDefaults} + {isDirty && (