Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/app-shell/src/views/__tests__/PageView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) => () => ({
t: (k: string) => defaults?.[k] ?? k,
}),
}));

vi.mock('../../providers/MetadataProvider', () => ({
Expand Down
63 changes: 63 additions & 0 deletions packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
values: Record<string, unknown>,
): 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']);

Expand Down Expand Up @@ -208,6 +247,7 @@ ComponentRegistry.register('form',
columns = 1,
onSubmit: onSubmitProp,
onChange: onChangeProp,
onDirtyChange: onDirtyChangeProp,
onCancel: onCancelProp,
resetOnSubmit = false,
validationMode = 'onSubmit',
Expand Down Expand Up @@ -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<string | undefined>(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<Record<string, unknown>>(
(defaultValues ?? {}) as Record<string, unknown>,
);
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<string, unknown>;
// 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]);

Expand All @@ -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<string, unknown>),
);
});
return () => subscription.unsubscribe();
}, [form, onDirtyChangeProp]);

// Handle form submission
const handleSubmit = form.handleSubmit(async (data) => {
setIsSubmitting(true);
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}',
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ const zh = {
saveSuccess: '保存成功',
saveError: '保存失败',
unsavedChanges: '您有未保存的更改,确定要离开吗?',
discardTitle: '放弃更改?',
discardMessage: '您有未保存的更改。如果现在关闭此表单,您的编辑将会丢失。',
keepEditing: '继续编辑',
discard: '放弃',
stepOf: '第{{current}}步,共{{total}}步',
createTitle: '新建{{object}}',
editTitle: '编辑{{object}}',
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-form/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
Loading
Loading