Reset to default on each Settings page with changed values #52

Merged
jcoffey-dev merged 1 commits from feature/reset-to-default into main 2026-09-28 23:47:20 +00:00
3 changed files with 136 additions and 13 deletions

No files matched your search

+59 -1
View File
@@ -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<string | null>(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 && <AccountLockButton accountId={objectId} disabled={saving} />}
{resettable.length > 0 && (
<Button type="button" variant="outline" disabled={saving} onClick={() => setResetOpen(true)}>
<RotateCcw className="h-4 w-4 mr-2" />
{t('form.resetToDefault', 'Reset to default')}
</Button>
)}
</div>
<div className="flex items-center gap-3">
{isDirty && (
@@ -989,6 +1006,47 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={resetOpen} onOpenChange={setResetOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('form.resetTitle', 'Put these back to their defaults?')}</AlertDialogTitle>
<AlertDialogDescription>
{t(
'form.resetBody',
'The form fills in the defaults. Nothing changes on the server until you save, and Cancel leaves it as it was.',
)}
</AlertDialogDescription>
</AlertDialogHeader>
<ul className="max-h-72 space-y-1 overflow-y-auto text-sm">
{resettable.map((f) => (
<li key={f.name} className="flex flex-wrap gap-x-2">
<span className="font-medium">{f.label}</span>
<span className="text-muted-foreground">
{f.now !== null
? t('form.resetFromTo', '{{now}} → {{was}}', { now: f.now, was: f.was })
: t('form.resetTo', '→ {{was}}', { was: f.was })}
</span>
</li>
))}
</ul>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel', 'Cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
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')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={deleteConfirmOpen}
onOpenChange={(open) => {
@@ -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<string, unknown>) => ({ 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([]);
});
});
+46 -11
View File
@@ -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<string, unknown>): 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<string, unknown>,
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<stri
schemaName = sch.schemaName;
} else {
const variant = sch.variants.find((v) => 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<string>();
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<string, unknown>): number {
return changedFields(schema, viewName, data).length;
}