From 393b51d4847bb0ff3b1c6f47341aa9c76770eef4 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 2 Jul 2026 14:52:28 +1000 Subject: [PATCH] fix: add sticky form update button (#3056) --- .../forms/branding-preferences-form.tsx | 100 +++++++++++---- .../forms/document-preferences-form.tsx | 26 ++-- .../forms/email-preferences-form.tsx | 27 ++-- .../components/forms/form-sticky-save-bar.tsx | 119 ++++++++++++++++++ .../forms/organisation-update-form.tsx | 39 ++---- .../app/components/forms/team-update-form.tsx | 39 ++---- .../o.$orgUrl.settings.branding.tsx | 3 + .../o.$orgUrl.settings.document.tsx | 2 + .../o.$orgUrl.settings.email.tsx | 2 + .../t.$teamUrl+/settings.branding.tsx | 3 + .../t.$teamUrl+/settings.document.tsx | 2 + .../t.$teamUrl+/settings.email.tsx | 2 + .../update-organisation-member-role.spec.ts | 2 +- .../envelope-expiration-settings.spec.ts | 12 +- .../include-document-certificate.spec.ts | 10 +- .../organisations/manage-organisation.spec.ts | 2 +- .../organisation-team-preferences.spec.ts | 16 +-- .../app-tests/e2e/teams/manage-team.spec.ts | 2 +- .../e2e/teams/team-settings-save-bar.spec.ts | 79 ++++++++++++ .../e2e/teams/team-signature-settings.spec.ts | 4 +- 20 files changed, 359 insertions(+), 132 deletions(-) create mode 100644 apps/remix/app/components/forms/form-sticky-save-bar.tsx create mode 100644 packages/app-tests/e2e/teams/team-settings-save-bar.spec.ts diff --git a/apps/remix/app/components/forms/branding-preferences-form.tsx b/apps/remix/app/components/forms/branding-preferences-form.tsx index e420fdae7..561fb5512 100644 --- a/apps/remix/app/components/forms/branding-preferences-form.tsx +++ b/apps/remix/app/components/forms/branding-preferences-form.tsx @@ -21,6 +21,8 @@ import { z } from 'zod'; import { useOptionalCurrentTeam } from '~/providers/team'; import { useCspNonce } from '~/utils/nonce'; +import { FormStickySaveBar } from './form-sticky-save-bar'; + const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp']; @@ -71,38 +73,82 @@ export function BrandingPreferencesForm({ const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors); const initialColors = parsedColors.success ? parsedColors.data : {}; + // The saved state the form maps to. Used both as the reactive `values` source and as + // the explicit target for a Reset (see handleReset). + const savedValues: TBrandingPreferencesFormSchema = { + brandingEnabled: settings.brandingEnabled ?? null, + brandingUrl: settings.brandingUrl ?? '', + brandingLogo: undefined, + brandingCompanyDetails: settings.brandingCompanyDetails ?? '', + brandingColors: initialColors, + brandingCss: settings.brandingCss ?? '', + }; + const form = useForm({ - values: { - brandingEnabled: settings.brandingEnabled ?? null, - brandingUrl: settings.brandingUrl ?? '', - brandingLogo: undefined, - brandingCompanyDetails: settings.brandingCompanyDetails ?? '', - brandingColors: initialColors, - brandingCss: settings.brandingCss ?? '', - }, + values: savedValues, resolver: zodResolver(ZBrandingPreferencesFormSchema), }); const isBrandingEnabled = form.watch('brandingEnabled'); + const getSavedLogoPreviewUrl = () => { + if (!settings.brandingLogo) { + return ''; + } + + const file = JSON.parse(settings.brandingLogo); + + if (!('type' in file) || !('data' in file)) { + return ''; + } + + const logoUrl = + context === 'Team' + ? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}` + : `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`; + + return `${logoUrl}?v=${Date.now()}`; + }; + useEffect(() => { - if (settings.brandingLogo) { - const file = JSON.parse(settings.brandingLogo); + const savedLogoPreviewUrl = getSavedLogoPreviewUrl(); - if ('type' in file && 'data' in file) { - const logoUrl = - context === 'Team' - ? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}` - : `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`; - - setPreviewUrl(logoUrl + '?v=' + Date.now()); - setHasLoadedPreview(true); - } + if (savedLogoPreviewUrl) { + setPreviewUrl(savedLogoPreviewUrl); } setHasLoadedPreview(true); }, [settings.brandingLogo]); + // Reset the form to the saved values. The form is driven by the `values` prop (no + // `defaultValues`), so `reset()` with no argument doesn't re-baseline the dirty check; + // passing the saved values clears the per-field dirty tracking (dirtyFields). + const handleReset = () => { + setPreviewUrl(getSavedLogoPreviewUrl()); + form.reset(savedValues); + }; + + // `formState.isDirty` is unreliable for a `values`-driven form: after a reset (or a + // save + refetch) it can stay true even though every field already matches its saved + // value and `dirtyFields` is empty. Derive the flag from `dirtyFields` instead so the + // sticky save bar reliably disappears. + const hasUnsavedChanges = Object.keys(form.formState.dirtyFields).length > 0; + + // Re-baseline the form to the just-saved state after a successful submit. The `values` + // prop re-syncs most fields once the route refetches, but write-only fields (the logo + // is a File that isn't reflected back into `values`) would otherwise stay dirty and + // keep the save bar visible. Relies on the page handler rethrowing on error so we only + // re-baseline on success. + const handleFormSubmit = form.handleSubmit(async (data) => { + try { + await onFormSubmit(data); + } catch { + return; + } + + form.reset(form.getValues()); + }); + // Cleanup ObjectURL on unmount or when previewUrl changes useEffect(() => { return () => { @@ -114,7 +160,7 @@ export function BrandingPreferencesForm({ return (
- +
- {!isBrandingEnabled &&
} + {!isBrandingEnabled &&
} - {!isBrandingEnabled &&
} + {!isBrandingEnabled &&
}
@@ -538,11 +584,11 @@ export function BrandingPreferencesForm({
)} -
- -
+
diff --git a/apps/remix/app/components/forms/document-preferences-form.tsx b/apps/remix/app/components/forms/document-preferences-form.tsx index ee552d043..f1a0b6d8a 100644 --- a/apps/remix/app/components/forms/document-preferences-form.tsx +++ b/apps/remix/app/components/forms/document-preferences-form.tsx @@ -21,7 +21,6 @@ import { ReminderSettingsPicker } from '@documenso/ui/components/document/remind import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select'; import { Alert } from '@documenso/ui/primitives/alert'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; -import { Button } from '@documenso/ui/primitives/button'; import { Combobox } from '@documenso/ui/primitives/combobox'; import { Form, @@ -46,6 +45,7 @@ import { z } from 'zod'; import { useOptionalCurrentTeam } from '~/providers/team'; import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox'; +import { FormStickySaveBar } from './form-sticky-save-bar'; /** * Can't infer this from the schema since we need to keep the schema inside the component to allow @@ -147,9 +147,21 @@ export const DocumentPreferencesForm = ({ resolver: zodResolver(ZDocumentPreferencesFormSchema), }); + const handleFormSubmit = form.handleSubmit(async (data) => { + try { + await onFormSubmit(data); + } catch { + // The page handler surfaces its own error toast. Keep the form dirty so + // the save bar stays visible and the user can retry. + return; + } + + form.reset(data); + }); + return (
- +
{!isPersonalLayoutMode && ( )} -
- -
+ form.reset()} + />
diff --git a/apps/remix/app/components/forms/email-preferences-form.tsx b/apps/remix/app/components/forms/email-preferences-form.tsx index 4ab981d93..818868005 100644 --- a/apps/remix/app/components/forms/email-preferences-form.tsx +++ b/apps/remix/app/components/forms/email-preferences-form.tsx @@ -4,7 +4,6 @@ import { DEFAULT_DOCUMENT_EMAIL_SETTINGS, ZDocumentEmailSettingsSchema } from '@ import { zEmail } from '@documenso/lib/utils/zod'; import { trpc } from '@documenso/trpc/react'; import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes'; -import { Button } from '@documenso/ui/primitives/button'; import { Form, FormControl, @@ -22,6 +21,8 @@ import type { TeamGlobalSettings } from '@prisma/client'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { FormStickySaveBar } from './form-sticky-save-bar'; + const ZEmailPreferencesFormSchema = z.object({ emailId: z.string().nullable(), emailReplyTo: zEmail().nullable(), @@ -59,9 +60,21 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema const emails = emailData?.data || []; + const handleFormSubmit = form.handleSubmit(async (data) => { + try { + await onFormSubmit(data); + } catch { + // The page handler surfaces its own error toast. Keep the form dirty so + // the save bar stays visible and the user can retry. + return; + } + + form.reset(data); + }); + return (
- +
{organisation.organisationClaim.flags.emailDomains && ( -
- -
+ form.reset()} + />
diff --git a/apps/remix/app/components/forms/form-sticky-save-bar.tsx b/apps/remix/app/components/forms/form-sticky-save-bar.tsx new file mode 100644 index 000000000..1d37f9482 --- /dev/null +++ b/apps/remix/app/components/forms/form-sticky-save-bar.tsx @@ -0,0 +1,119 @@ +import { cn } from '@documenso/ui/lib/utils'; +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'; + +export type FormStickySaveBarProps = { + isDirty: boolean; + isSubmitting: boolean; + onReset: () => void; +}; + +/** + * A single `position: sticky` bar rendered at the bottom of the form. + * + * - When the form's end is on screen it settles into place as a plain footer (just the + * Reset / Save buttons). + * - When the form's end is scrolled off, it sticks to the bottom of the viewport and + * shows the "unsaved changes" pill chrome. + * + * Because it's the same element in the form's flow, it auto-aligns to the form and the + * float <-> dock hand-off is a native, scroll-linked transition (no measurement, no + * 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) => { + const { t } = useLingui(); + + const sentinelRef = useRef(null); + const [isStuck, setIsStuck] = useState(false); + + useEffect(() => { + const sentinel = sentinelRef.current; + + if (!sentinel) { + return; + } + + // The sentinel sits at the bar's resting position (the end of the form). While the + // bar is stuck to the bottom of the viewport the sentinel is scrolled past (out of + // view); once you reach the form's end it comes into view and the bar settles. + const observer = new IntersectionObserver( + ([entry]) => { + setIsStuck(!entry.isIntersecting); + }, + { + root: null, + rootMargin: '0px 0px -24px 0px', + threshold: 0, + }, + ); + + observer.observe(sentinel); + + return () => { + observer.disconnect(); + }; + }, []); + + // Show the floating pill chrome only when there are unsaved changes AND the form's + // end is off screen. + const isFloating = isDirty && isStuck; + + return ( + <> +
+ + {isFloating && ( + + + + You have unsaved changes + + + )} + + +
+ {isDirty && ( + + )} + + +
+
+ + {/* Sentinel: detects when the sticky bar is floating (stuck) vs settled (docked). */} +
+ + ); +}; diff --git a/apps/remix/app/components/forms/organisation-update-form.tsx b/apps/remix/app/components/forms/organisation-update-form.tsx index 7ac15b1c7..5d4c2c88f 100644 --- a/apps/remix/app/components/forms/organisation-update-form.tsx +++ b/apps/remix/app/components/forms/organisation-update-form.tsx @@ -4,7 +4,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; import { ZUpdateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/update-organisation.types'; -import { Button } from '@documenso/ui/primitives/button'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -12,11 +11,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { AnimatePresence, motion } from 'framer-motion'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import type { z } from 'zod'; +import { FormStickySaveBar } from './form-sticky-save-bar'; + const ZOrganisationUpdateFormSchema = ZUpdateOrganisationRequestSchema.shape.data.pick({ name: true, url: true, @@ -137,36 +137,11 @@ export const OrganisationUpdateForm = () => { )} /> -
- - {form.formState.isDirty && ( - - - - )} - - - -
+ form.reset()} + /> diff --git a/apps/remix/app/components/forms/team-update-form.tsx b/apps/remix/app/components/forms/team-update-form.tsx index fdf569f95..8dddaee2b 100644 --- a/apps/remix/app/components/forms/team-update-form.tsx +++ b/apps/remix/app/components/forms/team-update-form.tsx @@ -2,7 +2,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; import { ZUpdateTeamRequestSchema } from '@documenso/trpc/server/team-router/update-team.types'; -import { Button } from '@documenso/ui/primitives/button'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -10,11 +9,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { AnimatePresence, motion } from 'framer-motion'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import type { z } from 'zod'; +import { FormStickySaveBar } from './form-sticky-save-bar'; + export type UpdateTeamDialogProps = { teamId: number; teamName: string; @@ -135,36 +135,11 @@ export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr )} /> -
- - {form.formState.isDirty && ( - - - - )} - - - -
+ form.reset()} + /> diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx index 9243b9d4c..707c4ad96 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx @@ -104,6 +104,9 @@ export default function OrganisationSettingsBrandingPage() { description: t`We were unable to update your branding preferences at this time, please try again later`, variant: 'destructive', }); + + // Rethrow so the form knows the save failed and keeps the unsaved changes. + throw err; } }; diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx index de9786aed..9d73e00bc 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx @@ -105,6 +105,8 @@ export default function OrganisationSettingsDocumentPage() { description: t`We were unable to update your document preferences at this time, please try again later`, variant: 'destructive', }); + + throw err; } }; diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email.tsx index 16144b1a5..0db136f7b 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email.tsx @@ -49,6 +49,8 @@ export default function OrganisationSettingsGeneral() { description: t`We were unable to update your email preferences at this time, please try again later`, variant: 'destructive', }); + + throw err; } }; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx index b674dc460..0401d4da2 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx @@ -99,6 +99,9 @@ export default function TeamsSettingsPage() { description: t`We were unable to update your branding preferences at this time, please try again later`, variant: 'destructive', }); + + // Rethrow so the form knows the save failed and keeps the unsaved changes. + throw err; } }; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx index b42f8a66e..fd734847f 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx @@ -96,6 +96,8 @@ export default function TeamsSettingsPage() { description: t`We were unable to update your document preferences at this time, please try again later`, variant: 'destructive', }); + + throw err; } }; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.email.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.email.tsx index e624c57e2..8b64a21bc 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.email.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.email.tsx @@ -49,6 +49,8 @@ export default function TeamEmailSettingsGeneral() { description: t`We were unable to update your email preferences at this time, please try again later`, variant: 'destructive', }); + + throw err; } }; diff --git a/packages/app-tests/e2e/admin/organisations/update-organisation-member-role.spec.ts b/packages/app-tests/e2e/admin/organisations/update-organisation-member-role.spec.ts index 2e0d53fb5..b73ed6f7f 100644 --- a/packages/app-tests/e2e/admin/organisations/update-organisation-member-role.spec.ts +++ b/packages/app-tests/e2e/admin/organisations/update-organisation-member-role.spec.ts @@ -526,7 +526,7 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page // Should be able to access organisation settings await expect(page.getByText('Organisation Settings')).toBeVisible(); await expect(page.getByLabel('Organisation Name*')).toBeVisible(); - await expect(page.getByRole('button', { name: 'Update organisation' })).toBeVisible(); + await expect(page.getByLabel('Organisation Name*')).toBeEnabled(); // Should have delete permissions await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible(); diff --git a/packages/app-tests/e2e/envelopes/envelope-expiration-settings.spec.ts b/packages/app-tests/e2e/envelopes/envelope-expiration-settings.spec.ts index 806585193..fa1545d17 100644 --- a/packages/app-tests/e2e/envelopes/envelope-expiration-settings.spec.ts +++ b/packages/app-tests/e2e/envelopes/envelope-expiration-settings.spec.ts @@ -19,7 +19,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level' }); // Wait for the form to load. - await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible(); + await expect(page.getByTestId('document-language-trigger')).toBeVisible(); // Change the amount to 2. const amountInput = page.getByTestId('envelope-expiration-amount'); @@ -35,7 +35,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level' await unitTrigger.click(); await page.getByRole('option', { name: 'Weeks' }).click(); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible(); // Verify via database. @@ -57,14 +57,14 @@ test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({ redirectPath: `/o/${organisation.url}/settings/document`, }); - await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible(); + await expect(page.getByTestId('document-language-trigger')).toBeVisible(); // Find the mode select (shows "Custom duration") and change to "Never expires". const modeTrigger = page.getByTestId('envelope-expiration-mode'); await modeTrigger.click(); await page.getByRole('option', { name: 'Never expires' }).click(); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible(); // Verify via database. @@ -109,7 +109,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p redirectPath: `/t/${team.url}/settings/document`, }); - await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible(); + await expect(page.getByTestId('document-language-trigger')).toBeVisible(); // The expiration picker mode select should show "Inherit from organisation" by default. const modeTrigger = page.getByTestId('envelope-expiration-mode'); @@ -128,7 +128,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p await unitTrigger.click(); await page.getByRole('option', { name: 'Days' }).click(); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible(); // Verify team setting is overridden. diff --git a/packages/app-tests/e2e/features/include-document-certificate.spec.ts b/packages/app-tests/e2e/features/include-document-certificate.spec.ts index 335fb2389..6d44581f6 100644 --- a/packages/app-tests/e2e/features/include-document-certificate.spec.ts +++ b/packages/app-tests/e2e/features/include-document-certificate.spec.ts @@ -324,10 +324,7 @@ test.describe('Signing Certificate Tests', () => { .click(); await page.getByRole('option', { name: 'No' }).click(); - await page - .getByRole('button', { name: /Update/ }) - .first() - .click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await page.waitForTimeout(1000); @@ -347,10 +344,7 @@ test.describe('Signing Certificate Tests', () => { .getByRole('combobox') .click(); await page.getByRole('option', { name: 'Yes' }).click(); - await page - .getByRole('button', { name: /Update/ }) - .first() - .click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await page.waitForTimeout(1000); diff --git a/packages/app-tests/e2e/organisations/manage-organisation.spec.ts b/packages/app-tests/e2e/organisations/manage-organisation.spec.ts index f14777b08..23fcdedc6 100644 --- a/packages/app-tests/e2e/organisations/manage-organisation.spec.ts +++ b/packages/app-tests/e2e/organisations/manage-organisation.spec.ts @@ -60,7 +60,7 @@ test('[ORGANISATIONS]: manage general settings', async ({ page }) => { await page.getByLabel('Organisation URL*').clear(); await page.getByLabel('Organisation URL*').fill(updatedOrganisationId); - await page.getByRole('button', { name: 'Update organisation' }).click(); + await page.getByRole('button', { name: 'Save changes' }).click(); // Check we have been redirected to the new organisation URL and the name is updated. await page.waitForURL(`/o/${updatedOrganisationId}/settings/general`); diff --git a/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts b/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts index 48ab68685..336222d93 100644 --- a/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts +++ b/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts @@ -39,7 +39,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => { await page.getByRole('option', { name: 'No' }).click(); await page.getByTestId('include-signing-certificate-trigger').click(); await page.getByRole('option', { name: 'No' }).click(); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible(); const teamSettings = await getTeamSettings({ @@ -73,7 +73,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => { await page.getByTestId('document-date-format-trigger').click(); await page.getByRole('option', { name: 'MM/DD/YYYY', exact: true }).click(); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible(); const updatedTeamSettings = await getTeamSettings({ @@ -128,7 +128,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => { await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com'); await page.getByRole('textbox', { name: 'Brand Details' }).click(); await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails'); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible(); const teamSettings = await getTeamSettings({ @@ -150,7 +150,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => { await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://example.com'); await page.getByRole('textbox', { name: 'Brand Details' }).click(); await page.getByRole('textbox', { name: 'Brand Details' }).fill('UpdatedBrandDetails'); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible(); const updatedTeamSettings = await getTeamSettings({ @@ -165,7 +165,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => { // Test inheritance by setting team back to inherit from organisation await page.getByTestId('enable-branding').click(); await page.getByRole('option', { name: 'Inherit from organisation' }).click(); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible(); await page.waitForTimeout(2000); @@ -208,7 +208,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => { await page.getByRole('checkbox', { name: 'Email the signer if the document is still pending' }).uncheck(); await page.getByRole('checkbox', { name: 'Email recipients when a pending document is deleted' }).uncheck(); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible(); const teamSettings = await getTeamSettings({ @@ -245,7 +245,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => { await page.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true }).uncheck(); await page.getByRole('checkbox', { name: 'Email the owner when the document is completed' }).uncheck(); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible(); const updatedTeamSettings = await getTeamSettings({ @@ -292,7 +292,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => { await page.getByRole('textbox', { name: 'Reply to email' }).fill(''); await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click(); await page.getByRole('option', { name: 'Inherit from organisation' }).click(); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible(); await page.waitForTimeout(1000); diff --git a/packages/app-tests/e2e/teams/manage-team.spec.ts b/packages/app-tests/e2e/teams/manage-team.spec.ts index 293ac0113..5e4892444 100644 --- a/packages/app-tests/e2e/teams/manage-team.spec.ts +++ b/packages/app-tests/e2e/teams/manage-team.spec.ts @@ -66,7 +66,7 @@ test('[TEAMS]: update team', async ({ page }) => { await page.getByLabel('Team URL*').clear(); await page.getByLabel('Team URL*').fill(updatedTeamId); - await page.getByRole('button', { name: 'Update team' }).click(); + await page.getByRole('button', { name: 'Save changes' }).click(); // Check we have been redirected to the new team URL and the name is updated. await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${updatedTeamId}/settings`); diff --git a/packages/app-tests/e2e/teams/team-settings-save-bar.spec.ts b/packages/app-tests/e2e/teams/team-settings-save-bar.spec.ts new file mode 100644 index 000000000..272b649e7 --- /dev/null +++ b/packages/app-tests/e2e/teams/team-settings-save-bar.spec.ts @@ -0,0 +1,79 @@ +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; + +import { apiSignin } from '../fixtures/authentication'; + +test('[TEAMS]: settings save bar docks at the bottom of the form', async ({ page }) => { + const { user, team } = await seedUser(); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/settings`, + }); + + await expect(page.getByLabel('Team Name*')).toBeVisible(); + + const saveButton = page.getByRole('button', { name: 'Save changes' }); + + // Pristine: the docked Save button is present but disabled; no Undo, no floating notice. + await expect(saveButton).toBeVisible(); + await expect(saveButton).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Undo' })).toHaveCount(0); + await expect(page.getByText('You have unsaved changes')).not.toBeVisible(); + + // Make a change → Save enables and Undo appears. + const updatedName = `team-${Date.now()}`; + await page.getByLabel('Team Name*').clear(); + await page.getByLabel('Team Name*').fill(updatedName); + + await expect(saveButton).toBeEnabled(); + await expect(page.getByRole('button', { name: 'Undo' })).toBeVisible(); + + // Undo → value restored, Save disabled again, Undo gone. + await page.getByRole('button', { name: 'Undo' }).click(); + await expect(page.getByLabel('Team Name*')).toHaveValue(team.name); + await expect(saveButton).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Undo' })).toHaveCount(0); + + // Change again → Save → success toast, returns to a pristine (disabled) state. + await page.getByLabel('Team Name*').clear(); + await page.getByLabel('Team Name*').fill(updatedName); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + + await expect(page.getByText('Your team has been successfully updated.').first()).toBeVisible(); + await expect(saveButton).toBeDisabled(); +}); + +test('[ORGANISATIONS]: settings save bar floats when the form footer is off-screen', async ({ page }) => { + const { user, organisation } = await seedUser({ + isPersonalOrganisation: false, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/o/${organisation.url}/settings/document`, + }); + + // Wait for the long document-preferences form to load. + await expect(page.getByTestId('document-language-trigger')).toBeVisible(); + + // Pristine: no floating notice even though the footer is below the fold. + await expect(page.getByText('You have unsaved changes')).not.toBeVisible(); + + // Edit a field near the top → the footer is off-screen, so the floating pill appears. + await page.getByTestId('document-language-trigger').click(); + await page.getByRole('option', { name: 'German' }).click(); + + await expect(page.getByText('You have unsaved changes')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible(); + + // Scroll to the footer → the floating pill merges into the docked buttons and the + // notice disappears. + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + + await expect(page.getByText('You have unsaved changes')).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible(); +}); diff --git a/packages/app-tests/e2e/teams/team-signature-settings.spec.ts b/packages/app-tests/e2e/teams/team-signature-settings.spec.ts index 7b4e4541e..078d0cab7 100644 --- a/packages/app-tests/e2e/teams/team-signature-settings.spec.ts +++ b/packages/app-tests/e2e/teams/team-signature-settings.spec.ts @@ -75,7 +75,7 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => { await item.click(); } - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); // Wait for the update to complete await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible(); @@ -140,7 +140,7 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => { await item.click(); } - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); // Wait for finish await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();