From dada691087e7b29d7e2647e2e16901fccbec4175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sat, 27 Jun 2026 23:05:43 +0800 Subject: [PATCH 1/2] fix(plugin-form): guard modal/drawer against accidental discard of unsaved input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create/edit forms shown as a modal or right-side drawer let users wipe their input by closing mid-entry — a backdrop click, Escape, or the X silently dropped everything they'd typed. Now an *accidental* close (backdrop / Escape / X) on a dirty form first asks "Discard changes?" (Keep editing / Discard), so input is never lost without confirmation. The explicit Cancel button is treated as an intentional discard and still closes immediately — no redundant prompt. Opt out per-form with `confirmOnDiscard: false`. Details: - Dirty state is computed in the form renderer via a normalized comparison (empty-ish values treated as equal) surfaced through a new `onDirtyChange` callback, instead of react-hook-form's identity-based `isDirty`, which false-positived on fields that self-normalize their empty value on mount (a pristine create form would otherwise prompt). - Both overlays render their action buttons in a sticky footer and route Cancel through the guard directly; the renderer's built-in Cancel did a `form.reset()` before onCancel, which emptied the form before the prompt could appear — so "Keep editing" kept an already-wiped form. - The discard AlertDialog is nested inside the Dialog/Sheet content so Radix stacks the layers correctly and its buttons receive clicks. - Discard-guard strings localized (en/zh) via createSafeTranslation. Adds discardGuard.test.tsx covering both overlays: Cancel closes directly even when dirty; accidental close prompts; Keep editing preserves input; Discard closes; confirmOnDiscard:false closes immediately. --- .../components/src/renderers/form/form.tsx | 63 ++++++ packages/i18n/src/locales/en.ts | 4 + packages/i18n/src/locales/zh.ts | 4 + packages/plugin-form/package.json | 1 + packages/plugin-form/src/DrawerForm.tsx | 189 ++++++++++++++-- packages/plugin-form/src/ModalForm.tsx | 112 +++++++++- .../plugin-form/src/discardGuard.test.tsx | 205 ++++++++++++++++++ packages/types/src/form.ts | 8 + pnpm-lock.yaml | 3 + 9 files changed, 563 insertions(+), 26 deletions(-) create mode 100644 packages/plugin-form/src/discardGuard.test.tsx diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index f380a68e50..bea4a9cc90 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -67,6 +67,45 @@ const useSafeFormTranslation = createSafeTranslation( 'common.selectOption', ); +// --- Dirty detection ------------------------------------------------------- +// react-hook-form's `isDirty` compares current values against `defaultValues` +// by strict identity, which produces false positives on a freshly opened form: +// several field widgets normalize their empty state on mount (e.g. '' -> null, +// undefined -> '', a cleared lookup -> null), and `null !== undefined` makes +// RHF flag an untouched create form as dirty. That made the discard-guard pop +// its "unsaved changes?" prompt even when the user typed nothing. We instead +// compute dirtiness ourselves with a comparison that treats all empty-ish +// values as equivalent, so only a genuine edit (empty <-> meaningful, or one +// meaningful value -> another) counts. +const isEmptyish = (v: unknown): boolean => + v === undefined || + v === null || + v === '' || + (Array.isArray(v) && v.length === 0); + +const valuesEqualForDirty = (a: unknown, b: unknown): boolean => { + if (isEmptyish(a) && isEmptyish(b)) return true; + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return a === b; + } +}; + +const computeDirty = ( + baseline: Record, + values: Record, +): boolean => { + const keys = new Set([ + ...Object.keys(baseline ?? {}), + ...Object.keys(values ?? {}), + ]); + for (const k of keys) { + if (!valuesEqualForDirty(baseline?.[k], values?.[k])) return true; + } + return false; +}; + const BUILTIN_FIELD_TYPES = new Set(['input', 'textarea', 'checkbox', 'switch', 'select']); const DATA_SOURCE_FIELD_TYPES = new Set(['lookup', 'master_detail', 'tree']); @@ -208,6 +247,7 @@ ComponentRegistry.register('form', columns = 1, onSubmit: onSubmitProp, onChange: onChangeProp, + onDirtyChange: onDirtyChangeProp, onCancel: onCancelProp, resetOnSubmit = false, validationMode = 'onSubmit', @@ -290,12 +330,21 @@ ComponentRegistry.register('form', // value so a genuine change (e.g. an edit-mode record finishing loading) // still resets, while identity churn is ignored. const lastDefaultsKey = React.useRef(undefined); + // The pristine snapshot the dirty check compares against. Kept in sync with + // whatever we last reset the form to (initial defaults, or a loaded record). + const baselineRef = React.useRef>( + (defaultValues ?? {}) as Record, + ); React.useEffect(() => { let key: string; try { key = JSON.stringify(defaultValues ?? {}); } catch { key = String(Date.now()); } if (lastDefaultsKey.current === key) return; lastDefaultsKey.current = key; form.reset(defaultValues); + baselineRef.current = (defaultValues ?? {}) as Record; + // A fresh reset is by definition pristine — clear any stale dirty signal + // (e.g. an edit-mode record that just finished loading). + onDirtyChangeProp?.(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [defaultValues]); @@ -313,6 +362,20 @@ ComponentRegistry.register('form', } }, [form, onAction]); + // Surface dirty state to the host (e.g. a modal/drawer guarding against + // accidental discard of unsaved input). We compute it via a normalized + // comparison against the pristine baseline (see computeDirty) rather than + // react-hook-form's `isDirty`, which false-positives on fields that + // self-normalize their empty value on mount. + React.useEffect(() => { + const subscription = form.watch((values) => { + onDirtyChangeProp?.( + computeDirty(baselineRef.current, values as Record), + ); + }); + return () => subscription.unsubscribe(); + }, [form, onDirtyChangeProp]); + // Handle form submission const handleSubmit = form.handleSubmit(async (data) => { setIsSubmitting(true); diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 1e9c89e477..1637e1e695 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -94,6 +94,10 @@ const en = { saveSuccess: 'Saved successfully', saveError: 'Failed to save', unsavedChanges: 'You have unsaved changes. Are you sure you want to leave?', + discardTitle: 'Discard changes?', + discardMessage: 'You have unsaved changes. If you close this form now, your edits will be lost.', + keepEditing: 'Keep editing', + discard: 'Discard', stepOf: 'Step {{current}} of {{total}}', createTitle: 'Create {{object}}', editTitle: 'Edit {{object}}', diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index a59faf191f..aaf0b05c0c 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -94,6 +94,10 @@ const zh = { saveSuccess: '保存成功', saveError: '保存失败', unsavedChanges: '您有未保存的更改,确定要离开吗?', + discardTitle: '放弃更改?', + discardMessage: '您有未保存的更改。如果现在关闭此表单,您的编辑将会丢失。', + keepEditing: '继续编辑', + discard: '放弃', stepOf: '第{{current}}步,共{{total}}步', createTitle: '新建{{object}}', editTitle: '编辑{{object}}', diff --git a/packages/plugin-form/package.json b/packages/plugin-form/package.json index a72ca4da56..dac20ff3f2 100644 --- a/packages/plugin-form/package.json +++ b/packages/plugin-form/package.json @@ -23,6 +23,7 @@ "@object-ui/components": "workspace:*", "@object-ui/core": "workspace:*", "@object-ui/fields": "workspace:*", + "@object-ui/i18n": "workspace:*", "@object-ui/permissions": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", diff --git a/packages/plugin-form/src/DrawerForm.tsx b/packages/plugin-form/src/DrawerForm.tsx index eb44ba9dea..f0aba656e2 100644 --- a/packages/plugin-form/src/DrawerForm.tsx +++ b/packages/plugin-form/src/DrawerForm.tsx @@ -13,7 +13,7 @@ * Aligns with @objectstack/spec FormView type: 'drawer' */ -import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import React, { useState, useCallback, useEffect, useMemo, useRef, useId } from 'react'; import type { FormField, DataSource } from '@object-ui/types'; import { Sheet, @@ -21,10 +21,21 @@ import { SheetHeader, SheetTitle, SheetDescription, + Button, cn, + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, } from '@object-ui/components'; +import { Loader2 } from 'lucide-react'; import { SchemaRenderer, useSafeFieldLabel, usePreviewMode } from '@object-ui/react'; +import { createSafeTranslation } from '@object-ui/i18n'; import { MasterDetailForm } from './MasterDetailForm'; import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields'; import { buildSectionFields as buildSectionFieldsShared } from './sectionFields'; @@ -43,6 +54,18 @@ const CONTAINER_GRID_COLS: Record = { 4: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4', }; +// Localized strings for the unsaved-changes guard. Falls back to English when +// no i18n provider is mounted (createSafeTranslation handles that). +const useDiscardTranslation = createSafeTranslation( + { + 'form.discardTitle': 'Discard changes?', + 'form.discardMessage': 'You have unsaved changes. If you close this form now, your edits will be lost.', + 'form.keepEditing': 'Keep editing', + 'form.discard': 'Discard', + }, + 'form.discardTitle', +); + export interface DrawerFormSectionConfig { name?: string; label?: string; @@ -76,6 +99,16 @@ export interface DrawerFormSchema { */ onOpenChange?: (open: boolean) => void; + /** + * Guard against *accidentally* discarding unsaved input. When the form has + * unsaved changes, an accidental close (backdrop click, Escape, or the X + * button) first asks the user to confirm. The explicit Cancel button is an + * intentional discard and always closes immediately. Set to `false` to drop + * the confirmation entirely. + * @default true + */ + confirmOnDiscard?: boolean; + /** * Drawer side. * @default 'right' @@ -130,16 +163,30 @@ export const DrawerForm: React.FC = ({ className, }) => { const { fieldLabel } = useSafeFieldLabel(); + const { t } = useDiscardTranslation(); const previewMode = usePreviewMode(); const [objectSchema, setObjectSchema] = useState(null); const [formFields, setFormFields] = useState([]); const [formData, setFormData] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Unsaved-changes guard (mirrors ModalForm). `isDirty` is fed up from the + // inner form renderer via onDirtyChange; `discardOpen` controls the confirm + // dialog shown when the user tries to close a dirty form. + const [isDirty, setIsDirty] = useState(false); + const [discardOpen, setDiscardOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const cancelIntentRef = useRef(false); + const confirmOnDiscard = schema.confirmOnDiscard !== false; const isOpen = schema.open !== false; const side = schema.drawerSide || 'right'; + // Stable form id so the footer's external submit button can target the + // inner
via the `form` attribute (actions live in the footer, not + // inside the form renderer — see baseFormSchema.showActions below). + const formId = useId(); + const [collapsedSections, setCollapsedSections] = useState>(() => { const init: Record = {}; schema.sections?.forEach((s, i) => { @@ -258,16 +305,17 @@ export const DrawerForm: React.FC = ({ // Handle form submission const handleSubmit = useCallback(async (data: Record) => { - if (!dataSource) { - if (schema.onSuccess) { - await schema.onSuccess(data); + setIsSubmitting(true); + try { + if (!dataSource) { + if (schema.onSuccess) { + await schema.onSuccess(data); + } + // Close drawer on success + schema.onOpenChange?.(false); + return data; } - // Close drawer on success - schema.onOpenChange?.(false); - return data; - } - try { let result; const payload = sanitizeFormData(data, objectSchema); if (schema.mode === 'create') { @@ -286,18 +334,42 @@ export const DrawerForm: React.FC = ({ schema.onError(err as Error); } throw err; + } finally { + setIsSubmitting(false); } }, [schema, dataSource, objectSchema]); - // Handle cancel - const handleCancel = useCallback(() => { - if (schema.onCancel) { - schema.onCancel(); + // Actually close the drawer, firing onCancel only when the close originated + // from the explicit Cancel button. + const finalizeClose = useCallback(() => { + setDiscardOpen(false); + if (cancelIntentRef.current) { + cancelIntentRef.current = false; + schema.onCancel?.(); } - // Close drawer on cancel schema.onOpenChange?.(false); }, [schema]); + // Attempt to close. With unsaved changes, intercept and ask for confirmation + // instead of discarding the user's input. `viaCancel` marks the Cancel button. + const attemptClose = useCallback((viaCancel: boolean) => { + cancelIntentRef.current = viaCancel; + if (confirmOnDiscard && isDirty) { + setDiscardOpen(true); + } else { + finalizeClose(); + } + }, [confirmOnDiscard, isDirty, finalizeClose]); + + // The explicit Cancel button is an *intentional* discard, so it closes + // immediately — no "Discard changes?" prompt. The unsaved-changes guard only + // intercepts *accidental* closes (backdrop click, Escape, the X), which Radix + // routes through onOpenChange below. (attemptClose stays for that path.) + const handleCancel = useCallback(() => { + cancelIntentRef.current = true; + finalizeClose(); + }, [finalizeClose]); + // Width style for the drawer content const widthStyle = useMemo(() => { if (!schema.drawerWidth) return undefined; @@ -311,18 +383,32 @@ export const DrawerForm: React.FC = ({ ? schema.layout : 'vertical'; + // Action buttons live in the drawer's own footer (not inside the form + // renderer). Routing Cancel through the footer lets it call the + // unsaved-changes guard directly; the form renderer's built-in Cancel does a + // `form.reset()` *before* invoking onCancel, which would wipe the user's + // input before the "Discard changes?" prompt even appears — so "Keep editing" + // would keep an already-emptied form. Mirrors ModalForm. + const showSubmit = schema.showSubmit !== false && schema.mode !== 'view'; + const showCancel = schema.showCancel !== false; + const submitLabel = schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'); + const cancelLabel = schema.cancelText || 'Cancel'; + // Build base form schema const baseFormSchema = { type: 'form' as const, objectName: schema.objectName, layout: formLayout, defaultValues: formData, - submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'), - cancelLabel: schema.cancelText, - showSubmit: schema.showSubmit !== false && schema.mode !== 'view', - showCancel: schema.showCancel !== false, + submitLabel, + cancelLabel, + showSubmit, + showCancel, onSubmit: handleSubmit, onCancel: handleCancel, + onDirtyChange: setIsDirty, // Feed unsaved-changes state up to the close guard + showActions: false, // Actions render in the drawer footer instead + id: formId, // Link the footer's submit button via the form attribute }; const renderContent = () => { @@ -447,7 +533,15 @@ export const DrawerForm: React.FC = ({ } return ( - + { + // Backdrop click, Escape, and the X button all route through here. + // Intercept closes so unsaved input isn't silently discarded. + if (open) { schema.onOpenChange?.(true); return; } + attemptClose(false); + }} + > = ({
{drawerBody}
+ + {/* Sticky footer — own action buttons. Cancel calls the discard guard + directly (no form.reset), so unsaved input survives "Keep editing". + Suppressed for the master-detail path, which owns its own action bar. */} + {!error && !loading && !(subforms?.length && schema.mode !== 'view') && (showSubmit || showCancel) && ( +
+
+ {showCancel && ( + + )} + {showSubmit && ( + + )} +
+
+ )} + + {/* Unsaved-changes guard — rendered INSIDE SheetContent so its portal + inherits the Sheet's Radix layer context (focus scope + dismissable + layer) through React. As a sibling of it had no such context, + so the still-open drawer swallowed its button clicks and "Keep + editing" did nothing. Nesting lets Radix stack the two overlays + correctly: the alert becomes the topmost layer and its buttons work. */} + + + + {t('form.discardTitle')} + + {t('form.discardMessage')} + + + + { cancelIntentRef.current = false; }}> + {t('form.keepEditing')} + + + {t('form.discard')} + + + +
); diff --git a/packages/plugin-form/src/ModalForm.tsx b/packages/plugin-form/src/ModalForm.tsx index 84dbec1d46..62eb9dda1d 100644 --- a/packages/plugin-form/src/ModalForm.tsx +++ b/packages/plugin-form/src/ModalForm.tsx @@ -13,7 +13,7 @@ * Aligns with @objectstack/spec FormView type: 'modal' */ -import React, { useState, useCallback, useEffect, useMemo, useId } from 'react'; +import React, { useState, useCallback, useEffect, useMemo, useId, useRef } from 'react'; import type { FormField, DataSource } from '@object-ui/types'; import { Dialog, @@ -28,17 +28,38 @@ import { TabsList, TabsTrigger, TabsContent, + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, } from '@object-ui/components'; import { Loader2 } from 'lucide-react'; import { FormSection } from './FormSection'; import { MasterDetailForm } from './MasterDetailForm'; import { SchemaRenderer, useSafeFieldLabel, usePreviewMode } from '@object-ui/react'; +import { createSafeTranslation } from '@object-ui/i18n'; import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields'; import { buildSectionFields as buildSectionFieldsShared } from './sectionFields'; import { applyAutoLayout, inferModalSize } from './autoLayout'; import { sanitizeFormData } from './sanitize'; import { usePermissions } from '@object-ui/permissions'; +// Localized strings for the unsaved-changes guard. Falls back to English when +// no i18n provider is mounted (createSafeTranslation handles that). +const useDiscardTranslation = createSafeTranslation( + { + 'form.discardTitle': 'Discard changes?', + 'form.discardMessage': 'You have unsaved changes. If you close this form now, your edits will be lost.', + 'form.keepEditing': 'Keep editing', + 'form.discard': 'Discard', + }, + 'form.discardTitle', +); + export interface ModalFormSectionConfig { name?: string; label?: string; @@ -85,6 +106,16 @@ export interface ModalFormSchema { */ modalCloseButton?: boolean; + /** + * Guard against *accidentally* discarding unsaved input. When the form has + * unsaved changes, an accidental close (backdrop click, Escape, or the X + * button) first asks the user to confirm. The explicit Cancel button is an + * intentional discard and always closes immediately. Set to `false` to drop + * the confirmation entirely. + * @default true + */ + confirmOnDiscard?: boolean; + // Common form props showSubmit?: boolean; submitText?: string; @@ -154,6 +185,7 @@ export const ModalForm: React.FC = ({ className, }) => { const { fieldLabel } = useSafeFieldLabel(); + const { t } = useDiscardTranslation(); const previewMode = usePreviewMode(); const perms = usePermissions(); // FLS gate: drop non-readable fields, disable non-editable ones. @@ -183,6 +215,16 @@ export const ModalForm: React.FC = ({ const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + // Unsaved-changes guard. `isDirty` is fed up from the inner form renderer via + // onDirtyChange; `discardOpen` controls the confirm dialog shown when the user + // tries to close a dirty form. + const [isDirty, setIsDirty] = useState(false); + const [discardOpen, setDiscardOpen] = useState(false); + // Whether the pending close came from the explicit Cancel button (so we fire + // schema.onCancel) versus a backdrop/Escape/X dismissal (which historically + // did not). Kept in a ref so it survives the confirm round-trip. + const cancelIntentRef = useRef(false); + const confirmOnDiscard = schema.confirmOnDiscard !== false; const isOpen = schema.open !== false; @@ -363,15 +405,37 @@ export const ModalForm: React.FC = ({ } }, [schema, dataSource, objectSchema, perms]); - // Handle cancel - const handleCancel = useCallback(() => { - if (schema.onCancel) { - schema.onCancel(); + // Actually close the modal, firing onCancel only when the close originated + // from the explicit Cancel button. + const finalizeClose = useCallback(() => { + setDiscardOpen(false); + if (cancelIntentRef.current) { + cancelIntentRef.current = false; + schema.onCancel?.(); } - // Close modal on cancel schema.onOpenChange?.(false); }, [schema]); + // Attempt to close. With unsaved changes, intercept and ask for confirmation + // instead of discarding the user's input. `viaCancel` marks the Cancel button. + const attemptClose = useCallback((viaCancel: boolean) => { + cancelIntentRef.current = viaCancel; + if (confirmOnDiscard && isDirty) { + setDiscardOpen(true); + } else { + finalizeClose(); + } + }, [confirmOnDiscard, isDirty, finalizeClose]); + + // The explicit Cancel button is an *intentional* discard, so it closes + // immediately — no "Discard changes?" prompt. The unsaved-changes guard only + // intercepts *accidental* closes (backdrop click, Escape, the X), which Radix + // routes through onOpenChange below. (attemptClose stays for that path.) + const handleCancel = useCallback(() => { + cancelIntentRef.current = true; + finalizeClose(); + }, [finalizeClose]); + const formLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal') ? schema.layout : 'vertical'; @@ -394,6 +458,7 @@ export const ModalForm: React.FC = ({ showCancel, onSubmit: handleSubmit, onCancel: handleCancel, + onDirtyChange: setIsDirty, // Feed unsaved-changes state up to the close guard showActions: false, // Hide actions — rendered in sticky footer id: formId, // Link external submit button via form attribute }; @@ -562,7 +627,15 @@ export const ModalForm: React.FC = ({ const hasFooter = !loading && !error && (showSubmit || showCancel); return ( - + { + // Radix routes backdrop click, Escape, and the X button all through + // here. Intercept closes so unsaved input isn't silently discarded. + if (open) { schema.onOpenChange?.(true); return; } + attemptClose(false); + }} + > {(schema.title || schema.description) && ( @@ -610,6 +683,31 @@ export const ModalForm: React.FC = ({ )} + + {/* Unsaved-changes guard — rendered INSIDE DialogContent so its portal + inherits the Dialog's Radix layer context (focus scope + dismissable + layer) through React. As a sibling of it had no such + context, so the still-open modal swallowed its button clicks and + "Keep editing" did nothing. Nesting lets Radix stack the two modals + correctly: the alert becomes the topmost layer and its buttons work. */} + + + + {t('form.discardTitle')} + + {t('form.discardMessage')} + + + + { cancelIntentRef.current = false; }}> + {t('form.keepEditing')} + + + {t('form.discard')} + + + + ); diff --git a/packages/plugin-form/src/discardGuard.test.tsx b/packages/plugin-form/src/discardGuard.test.tsx new file mode 100644 index 0000000000..95ad256f68 --- /dev/null +++ b/packages/plugin-form/src/discardGuard.test.tsx @@ -0,0 +1,205 @@ +/** + * Unsaved-changes guard — ModalForm & DrawerForm. + * + * Closing a create/edit overlay *accidentally* (backdrop click, Escape, the X) + * while the form has unsaved input must not silently discard that input — those + * paths intercept with a "Discard changes?" confirmation. + * + * The explicit **Cancel button**, by contrast, is an intentional discard: it + * closes immediately with no prompt, even when the form is dirty. Re-prompting + * on a deliberate Cancel is just friction. These tests pin both behaviours. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; +import { registerAllFields } from '@object-ui/fields'; +import { ModalForm } from './ModalForm'; +import { DrawerForm } from './DrawerForm'; + +registerAllFields(); + +const ds: any = { + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'task', + // A spread of field types — number/select/date/boolean widgets render an + // empty value that isn't strictly `undefined`, which used to trip + // react-hook-form's `isDirty` and make a pristine create form prompt on + // close. The dirty check must treat all of these as still-pristine. + fields: { + title: { type: 'text', label: 'Title' }, + count: { type: 'number', label: 'Count' }, + status: { type: 'select', label: 'Status', options: [{ label: 'A', value: 'a' }] }, + due: { type: 'date', label: 'Due' }, + done: { type: 'boolean', label: 'Done' }, + }, + }), + create: vi.fn().mockResolvedValue({ id: '1' }), + update: vi.fn(), + findOne: vi.fn(), +}; + +beforeEach(() => vi.clearAllMocks()); + +/** Type into the first text input to dirty the form. */ +async function dirtyTheForm() { + const input = await waitFor(() => { + const el = document.querySelector('input') as HTMLInputElement | null; + if (!el) throw new Error('no input yet'); + return el; + }); + fireEvent.change(input, { target: { value: 'hello world' } }); +} + +/** + * The accidental-close path. Radix routes the X (and Escape/backdrop) through + * the overlay's `onOpenChange(false)` — the guard only intercepts these, not + * the explicit Cancel button. The X carries an sr-only "Close" label. + */ +function accidentalClose() { + fireEvent.click(screen.getByRole('button', { name: 'Close' })); +} + +describe('ModalForm unsaved-changes guard', () => { + it('Cancel closes immediately when the form is pristine (no confirm)', async () => { + const onOpenChange = vi.fn(); + render( + , + ); + await waitFor(() => expect(screen.getByTestId('modal-form-footer')).toBeTruthy()); + + fireEvent.click(screen.getByText('Cancel')); + + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(screen.queryByText('Discard changes?')).toBeNull(); + }); + + it('Cancel closes immediately even when dirty (intentional discard, no confirm)', async () => { + const onOpenChange = vi.fn(); + const onCancel = vi.fn(); + render( + , + ); + await waitFor(() => expect(screen.getByTestId('modal-form-footer')).toBeTruthy()); + await dirtyTheForm(); + + fireEvent.click(screen.getByText('Cancel')); + + // No prompt — the overlay closes straight away and onCancel fires. + expect(screen.queryByText('Discard changes?')).toBeNull(); + expect(onCancel).toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('accidental close (the X) intercepts with a confirm dialog when dirty', async () => { + const onOpenChange = vi.fn(); + render( + , + ); + await waitFor(() => expect(screen.getByTestId('modal-form-footer')).toBeTruthy()); + await dirtyTheForm(); + + accidentalClose(); + + // Confirmation shown, overlay NOT yet closed. + await waitFor(() => expect(screen.getByText('Discard changes?')).toBeTruthy()); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('"Keep editing" dismisses the confirm and leaves the form open', async () => { + const onOpenChange = vi.fn(); + render( + , + ); + await waitFor(() => expect(screen.getByTestId('modal-form-footer')).toBeTruthy()); + await dirtyTheForm(); + accidentalClose(); + await waitFor(() => expect(screen.getByText('Discard changes?')).toBeTruthy()); + + fireEvent.click(screen.getByText('Keep editing')); + + await waitFor(() => expect(screen.queryByText('Discard changes?')).toBeNull()); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('"Discard" confirms and closes the overlay', async () => { + const onOpenChange = vi.fn(); + render( + , + ); + await waitFor(() => expect(screen.getByTestId('modal-form-footer')).toBeTruthy()); + await dirtyTheForm(); + accidentalClose(); + await waitFor(() => expect(screen.getByText('Discard changes?')).toBeTruthy()); + + fireEvent.click(screen.getByText('Discard')); + + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)); + }); + + it('confirmOnDiscard: false closes immediately even on an accidental close', async () => { + const onOpenChange = vi.fn(); + render( + , + ); + await waitFor(() => expect(screen.getByTestId('modal-form-footer')).toBeTruthy()); + await dirtyTheForm(); + + accidentalClose(); + + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(screen.queryByText('Discard changes?')).toBeNull(); + }); +}); + +describe('DrawerForm unsaved-changes guard', () => { + it('Cancel closes immediately even when dirty (intentional discard, no confirm)', async () => { + const onOpenChange = vi.fn(); + render( + , + ); + await dirtyTheForm(); + + fireEvent.click(screen.getByText('Cancel')); + + expect(screen.queryByText('Discard changes?')).toBeNull(); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('accidental close (the X) intercepts with a confirm dialog when dirty', async () => { + const onOpenChange = vi.fn(); + render( + , + ); + await dirtyTheForm(); + + accidentalClose(); + + await waitFor(() => expect(screen.getByText('Discard changes?')).toBeTruthy()); + expect(onOpenChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 072028f977..4e40d48c27 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -965,6 +965,14 @@ export interface FormSchema extends BaseSchema { * Change handler (called on any field change) */ onChange?: (data: Record) => void; + /** + * Dirty-state handler — fires whenever the form transitions between + * pristine and edited (react-hook-form's `formState.isDirty`). Overlay + * hosts (modal/drawer) use this to guard against accidentally discarding + * unsaved input when the user clicks the backdrop, presses Escape, or hits + * the close/cancel button. + */ + onDirtyChange?: (isDirty: boolean) => void; /** * Cancel handler */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dadcda6dbd..6f71361dc6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1791,6 +1791,9 @@ importers: '@object-ui/fields': specifier: workspace:* version: link:../fields + '@object-ui/i18n': + specifier: workspace:* + version: link:../i18n '@object-ui/permissions': specifier: workspace:* version: link:../permissions From d977bd38792cedb3deade5adc3872f09318d271f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sat, 27 Jun 2026 23:35:43 +0800 Subject: [PATCH 2/2] test(app-shell): add createSafeTranslation to PageView i18n mock PageView.test.tsx transitively imports ObjectForm -> Drawer/ModalForm, which now build their discard-guard strings via createSafeTranslation. The test's full i18n module mock didn't export it, so the suite failed to load. Add a faithful stub that echoes the supplied English defaults. --- packages/app-shell/src/views/__tests__/PageView.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/app-shell/src/views/__tests__/PageView.test.tsx b/packages/app-shell/src/views/__tests__/PageView.test.tsx index 20a6a4ec10..815618c3c5 100644 --- a/packages/app-shell/src/views/__tests__/PageView.test.tsx +++ b/packages/app-shell/src/views/__tests__/PageView.test.tsx @@ -38,6 +38,12 @@ vi.mock('@object-ui/i18n', () => ({ fieldOptionLabel: (_o: any, _f: any, _v: any, l: any) => l, actionParamText: (_o: any, _a: any, _p: any, _attr: any, fallback: any) => fallback, }), + // ObjectForm → Modal/DrawerForm build their discard-guard strings with this; + // return a hook that just echoes the supplied English defaults. + createSafeTranslation: + (defaults: Record) => () => ({ + t: (k: string) => defaults?.[k] ?? k, + }), })); vi.mock('../../providers/MetadataProvider', () => ({