diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index ae05aec..1942944 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -41,7 +41,7 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { ArrowLeft, ChevronDown, Save, Trash2, Loader2 } from 'lucide-react'; +import { ArrowLeft, ChevronDown, RotateCcw, Save, Trash2, Loader2 } from 'lucide-react'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { AccountLockButton } from '@/features/lock/AccountLockButton'; @@ -66,6 +66,7 @@ import { toast } from '@/hooks/use-toast'; import { logFormChange } from '@/lib/debug'; import { FieldWidget } from '@/components/forms/FieldWidget'; import { DnsConnectCard } from '@/features/dns/DnsConnectCard'; +import { changedFields } from '@/features/settings/changedFromDefault'; import { isSieveScriptField } from '@/lib/sievepad'; import { reloadActionFor } from '@/lib/settingsApply'; @@ -115,6 +116,7 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic const [createdObjectId, setCreatedObjectId] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [resetOpen, setResetOpen] = useState(false); const currentFields = useMemo((): Fields | null => { if (!resolved) return null; @@ -771,6 +773,15 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic const { sch } = resolved; const readOnly = !canEdit; + // inbuxa: what "Reset to default" would put back on a settings page (settings-reorg). + const resettable = + isSingleton && !readOnly + ? changedFields(schema, viewName, formData, { + on: t('field.on', 'On'), + off: t('field.off', 'Off'), + none: t('field.none', 'None'), + }) + : []; const combinedForm: Form | null = (() => { if (sch.type !== 'multiple') return currentForm; @@ -944,6 +955,12 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic )} {/* inbuxa: lock or unlock a person beside Delete (AL-1) */} {viewName === 'x:Account/User' && objectId && } + {resettable.length > 0 && ( + + )}
{isDirty && ( @@ -989,6 +1006,47 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic + + + + {t('form.resetTitle', 'Put these back to their defaults?')} + + {t( + 'form.resetBody', + 'The form fills in the defaults. Nothing changes on the server until you save, and Cancel leaves it as it was.', + )} + + +
    + {resettable.map((f) => ( +
  • + {f.label} + + {f.now !== null + ? t('form.resetFromTo', '{{now}} → {{was}}', { now: f.now, was: f.was }) + : t('form.resetTo', '→ {{was}}', { was: f.was })} + +
  • + ))} +
+ + {t('common.cancel', 'Cancel')} + { + setFormData((prev) => { + const next = { ...prev }; + for (const f of resettable) next[f.name] = structuredClone(f.defaultValue); + return next; + }); + setFieldErrors({}); + }} + > + {t('form.resetConfirm', 'Fill in defaults')} + + +
+
+ { diff --git a/src/features/settings/changedFromDefault.test.ts b/src/features/settings/changedFromDefault.test.ts index 033bd69..f7baede 100644 --- a/src/features/settings/changedFromDefault.test.ts +++ b/src/features/settings/changedFromDefault.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect } from 'vitest'; -import { countChanged, singletonPages } from './changedFromDefault'; +import { changedFields, countChanged, singletonPages } from './changedFromDefault'; import type { Schema } from '@/types/schema'; const field = (type: Record) => ({ description: '', type, update: 'mutable' }); @@ -86,3 +86,33 @@ describe('singletonPages', () => { ]); }); }); + +describe('changedFields', () => { + it('names each changed field with its value and default in words', () => { + expect(changedFields(schema, 'x:Imap', { allowPlainText: true, timeout: 600000, banner: 'hi' })).toEqual([ + { name: 'allowPlainText', label: 'allowPlainText', now: 'on', was: 'off', defaultValue: false }, + { name: 'timeout', label: 'timeout', now: expect.any(String), was: expect.any(String), defaultValue: 1800000 }, + ]); + }); + + it('leaves out fields nobody can change', () => { + const serverSet = { + ...schema, + fields: { + ...schema.fields, + 'x:Imap': { + ...schema.fields['x:Imap'], + properties: { + ...schema.fields['x:Imap'].properties, + timeout: { ...field({ type: 'number', format: 'duration' }), update: 'serverSet' }, + }, + }, + }, + } as unknown as Schema; + expect(changedFields(serverSet, 'x:Imap', { timeout: 5 }).map((f) => f.name)).toEqual([]); + }); + + it('is empty when nothing differs', () => { + expect(changedFields(schema, 'x:Imap', { allowPlainText: false })).toEqual([]); + }); +}); diff --git a/src/features/settings/changedFromDefault.ts b/src/features/settings/changedFromDefault.ts index 3a8a35e..7171d04 100644 --- a/src/features/settings/changedFromDefault.ts +++ b/src/features/settings/changedFromDefault.ts @@ -42,12 +42,31 @@ export function singletonPages(schema: Schema, items: LayoutItem[]): SettingsPag return out; } -/** How many of a page's fields differ from their defaults, given the object's current value. */ -export function countChanged(schema: Schema, viewName: string, data: Record): number { +export interface ChangedField { + name: string; + label: string; + /** The current value in words, or null when there is nothing short to say. */ + now: string | null; + /** The default in words. */ + was: string; + defaultValue: unknown; +} + +/** + * The fields on a page that differ from their defaults, given the object's + * current value: what "Reset to default" puts back. Server-set fields are + * left out, since nobody can change them. + */ +export function changedFields( + schema: Schema, + viewName: string, + data: Record, + words: { on: string; off: string; none: string } = WORDS, +): ChangedField[] { const resolved = resolveObject(schema, viewName); - if (!resolved) return 0; + if (!resolved) return []; const sch = resolveSchema(schema, resolved.objectName); - if (!sch) return 0; + if (!sch) return []; let scope: string; let schemaName: string; @@ -56,23 +75,39 @@ export function countChanged(schema: Schema, viewName: string, data: Record v.name === data['@type']); - if (!variant?.schemaName) return 0; + if (!variant?.schemaName) return []; scope = variant.schemaName; schemaName = variant.schemaName; } const fields = schema.fields[scope] ?? schema.fields[schemaName]; const form = resolveForm(schema, viewName, resolved.objectName, schemaName); - if (!fields || !form) return 0; + if (!fields || !form) return []; - let changed = 0; + const out: ChangedField[] = []; + const seen = new Set(); for (const section of form.sections) { - for (const { name } of section.fields) { + for (const formField of section.fields) { + const { name } = formField; const field = fields.properties[name]; - if (!field) continue; + if (!field || seen.has(name) || field.update === 'serverSet') continue; + seen.add(name); const def = fields.defaults?.[name]; - if (describeDefault(field, def, schema, WORDS) !== null && differsFromDefault(data[name], def)) changed++; + const was = describeDefault(field, def, schema, words); + if (was === null || !differsFromDefault(data[name], def)) continue; + out.push({ + name, + label: formField.label || name, + now: describeDefault(field, data[name], schema, words), + was, + defaultValue: def, + }); } } - return changed; + return out; +} + +/** How many of a page's fields differ from their defaults, given the object's current value. */ +export function countChanged(schema: Schema, viewName: string, data: Record): number { + return changedFields(schema, viewName, data).length; }