Reset to default on each Settings page with changed values #52
No files matched your search
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user