Skip to content

Commit bb3b384

Browse files
fylornclaude
andcommitted
feat(settings): inline autosave per field + tab in URL
The giant monolithic "Save" button is gone. Editing a setting now commits on its own — each scalar field has a tiny save-state indicator (spinner / ✓ / ! with error tooltip) right next to its label. No more scrolling back to the top of the page, no more wondering whether the banner's success message meant all 40 fields or the one you care about, and no more risk of one operator's save clobbering another's cross-tab edits. How it works: - useFieldAutosave hook watches (value, last-saved) per field. When they diverge it schedules a PATCH with a per-type debounce: 600ms for text/number (so the request fires once typing settles) and 0ms for switches/selects (toggle is intent, commit immediately). Reverting to the saved value cancels the pending timer so nothing flickers. - patchOne(key, v) posts the minimum `{settings: {[key]: v}}` body to /api/admin/settings. Backend is already merge-semantics so no backend change needed. - SaveIndicator renders the state machine as a single 14px icon. Errors stick until the next successful save and carry the server message in a tooltip. - NumberField grew an optional `indicator` slot so all existing call sites could drop the state icon into place without restructuring the layout. Two groups keep explicit save buttons because they're not per-field-semantics: - OIDC — secret is write-only, issuer changes trigger provider re-discovery, so grouping is safer. New local "Save SSO" button. - PlatformPricing — already had its own flow; left alone. The read-only gateway request-timeout / body-limit fields stay read-only; they need a restart to apply and shouldn't invite editing. Also, Settings tabs now sync to the URL: - /admin/settings?tab=auth deep-links directly to the Auth tab. - The router's validateSearch whitelists the 7 valid ids; unknown values fall through to the General tab. - Switching tabs uses `replace: true` so the history stack doesn't fill up with every click. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 21be845 commit bb3b384

7 files changed

Lines changed: 360 additions & 129 deletions

File tree

web/src/i18n/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -915,7 +915,8 @@
915915
"secretEnterPlaceholder": "Enter client secret",
916916
"secretConfiguredHint": "Secret is configured. Leave empty to keep unchanged.",
917917
"redirectUrl": "Redirect URL",
918-
"redirectUrlHint": "Must match the callback URL registered with your OIDC provider"
918+
"redirectUrlHint": "Must match the callback URL registered with your OIDC provider",
919+
"save": "Save SSO settings"
919920
},
920921
"general": "General",
921922
"auth": "Authentication",
@@ -927,6 +928,7 @@
927928
"perf": "Performance",
928929
"saved": "Settings saved successfully",
929930
"saveError": "Failed to save settings",
931+
"autosaveHint": "Changes save automatically",
930932
"requiresRestart": "Requires restart to take effect",
931933
"addRule": "Add Rule",
932934
"sandbox": {

web/src/i18n/zh.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -915,7 +915,8 @@
915915
"secretEnterPlaceholder": "输入客户端密钥",
916916
"secretConfiguredHint": "已配置密钥。留空则保持不变。",
917917
"redirectUrl": "回调地址",
918-
"redirectUrlHint": "必须与在 OIDC 提供商处注册的回调 URL 一致"
918+
"redirectUrlHint": "必须与在 OIDC 提供商处注册的回调 URL 一致",
919+
"save": "保存 SSO 配置"
919920
},
920921
"general": "通用",
921922
"auth": "认证",
@@ -927,6 +928,7 @@
927928
"perf": "性能调优",
928929
"saved": "设置保存成功",
929930
"saveError": "保存设置失败",
931+
"autosaveHint": "改动自动保存",
930932
"requiresRestart": "需要重启生效",
931933
"addRule": "添加规则",
932934
"sandbox": {

web/src/router.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,10 +292,23 @@ const rolesRoute = createRoute({
292292
component: RolesPage,
293293
});
294294

295+
const SETTINGS_TABS = ['general', 'auth', 'gateway', 'security', 'apikeys', 'audit', 'perf'] as const;
296+
type SettingsTab = (typeof SETTINGS_TABS)[number];
297+
295298
const settingsRoute = createRoute({
296299
getParentRoute: () => rootRoute,
297300
path: '/admin/settings',
298301
component: SettingsPage,
302+
// Deep-link to a specific tab via `?tab=auth`. Unknown values fall
303+
// through as undefined so the page defaults to the first tab.
304+
validateSearch: (s: Record<string, unknown>): { tab?: SettingsTab } => {
305+
const t = s.tab;
306+
return {
307+
tab: typeof t === 'string' && (SETTINGS_TABS as readonly string[]).includes(t)
308+
? (t as SettingsTab)
309+
: undefined,
310+
};
311+
},
299312
});
300313

301314
const logForwardersRoute = createRoute({

web/src/routes/admin/settings.tsx

Lines changed: 206 additions & 125 deletions
Large diffs are not rendered by default.

web/src/routes/admin/settings/NumberField.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import type { ReactNode } from 'react';
12
import { Input } from '@/components/ui/input';
23
import { Label } from '@/components/ui/label';
34

45
/// Small wrapper around `<Input type="number">` with a label and
56
/// optional hint. Used everywhere on the Settings page for TTL /
6-
/// timeout / retention inputs.
7+
/// timeout / retention inputs. The optional `indicator` slot sits
8+
/// right of the label so inline-save state is visible without
9+
/// hijacking the input row.
710
export function NumberField({
811
label,
912
value,
@@ -12,6 +15,7 @@ export function NumberField({
1215
max,
1316
hint,
1417
readOnly,
18+
indicator,
1519
}: {
1620
label: string;
1721
value: number;
@@ -20,10 +24,14 @@ export function NumberField({
2024
max?: number;
2125
hint?: string;
2226
readOnly?: boolean;
27+
indicator?: ReactNode;
2328
}) {
2429
return (
2530
<div className="space-y-1">
26-
<Label className="text-sm">{label}</Label>
31+
<div className="flex items-center justify-between gap-2">
32+
<Label className="text-sm">{label}</Label>
33+
{indicator}
34+
</div>
2735
<Input
2836
type="number"
2937
value={value}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Check, AlertCircle, Loader2 } from 'lucide-react';
2+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
3+
import type { FieldSaveState } from './useFieldAutosave';
4+
5+
interface SaveIndicatorProps {
6+
state: FieldSaveState;
7+
error: string | null;
8+
}
9+
10+
/// Compact inline-save status marker. Rendered to the right of the input
11+
/// so the user always sees the save outcome where the change happened —
12+
/// no more hunting for a page-level banner after each edit.
13+
export function SaveIndicator({ state, error }: SaveIndicatorProps) {
14+
if (state === 'idle') {
15+
return <span className="inline-block h-3.5 w-3.5 shrink-0" aria-hidden="true" />;
16+
}
17+
if (state === 'saving') {
18+
return (
19+
<Loader2
20+
className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground"
21+
aria-label="Saving"
22+
/>
23+
);
24+
}
25+
if (state === 'saved') {
26+
return (
27+
<Check
28+
className="h-3.5 w-3.5 shrink-0 text-emerald-500"
29+
aria-label="Saved"
30+
/>
31+
);
32+
}
33+
return (
34+
<Tooltip>
35+
<TooltipTrigger asChild>
36+
<AlertCircle
37+
className="h-3.5 w-3.5 shrink-0 cursor-help text-destructive"
38+
aria-label={error ?? 'Save failed'}
39+
/>
40+
</TooltipTrigger>
41+
<TooltipContent side="top">{error ?? 'Save failed'}</TooltipContent>
42+
</Tooltip>
43+
);
44+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
3+
export type FieldSaveState = 'idle' | 'saving' | 'saved' | 'error';
4+
5+
interface UseFieldAutosaveOpts<T> {
6+
/** Current value from the page's form state. */
7+
value: T;
8+
/** True once the initial GET has populated the form — prevents the hook
9+
* from firing a PATCH the moment the page finishes loading. */
10+
isLoaded: boolean;
11+
/** Called when the hook decides the value differs from the last-persisted
12+
* value. Should resolve on success, throw on failure. */
13+
persist: (value: T) => Promise<void>;
14+
/** Debounce window in ms. Text / number → ~600ms; switches and selects
15+
* pass 0 so the save fires as soon as the user commits. */
16+
debounceMs?: number;
17+
}
18+
19+
/// Inline-save for a single scalar setting. The hook watches `value`, and
20+
/// when it diverges from the last-persisted snapshot it schedules a
21+
/// debounced PATCH. State transitions: idle → saving → saved → idle (after
22+
/// a short acknowledgement window) or → error (which sticks until the
23+
/// next successful save).
24+
export function useFieldAutosave<T>({
25+
value,
26+
isLoaded,
27+
persist,
28+
debounceMs = 600,
29+
}: UseFieldAutosaveOpts<T>): { state: FieldSaveState; error: string | null } {
30+
const [state, setState] = useState<FieldSaveState>('idle');
31+
const [error, setError] = useState<string | null>(null);
32+
33+
// `persist` gets re-created on every render from the caller (inline
34+
// arrow), so we capture it in a ref instead of depending on it in the
35+
// effect — otherwise the timer would restart every render and never
36+
// actually fire.
37+
const persistRef = useRef(persist);
38+
persistRef.current = persist;
39+
40+
// Snapshot of the last value the server acknowledged. Seeded on first
41+
// load so the hook doesn't interpret "page just populated" as a change.
42+
const lastSavedRef = useRef<T | null>(null);
43+
const seededRef = useRef(false);
44+
45+
useEffect(() => {
46+
if (!isLoaded) return;
47+
if (!seededRef.current) {
48+
lastSavedRef.current = value;
49+
seededRef.current = true;
50+
return;
51+
}
52+
if (value === lastSavedRef.current) {
53+
// Reverted to the saved value — cancel any pending acknowledgement
54+
// so the field doesn't flicker a stale ✓.
55+
setState('idle');
56+
setError(null);
57+
return;
58+
}
59+
60+
const id = setTimeout(async () => {
61+
setState('saving');
62+
setError(null);
63+
try {
64+
await persistRef.current(value);
65+
lastSavedRef.current = value;
66+
setState('saved');
67+
// Clear the ✓ after a beat so the row settles back to calm.
68+
setTimeout(() => {
69+
setState((s) => (s === 'saved' ? 'idle' : s));
70+
}, 1500);
71+
} catch (err) {
72+
setState('error');
73+
setError(err instanceof Error ? err.message : 'Save failed');
74+
}
75+
}, debounceMs);
76+
77+
return () => clearTimeout(id);
78+
}, [value, isLoaded, debounceMs]);
79+
80+
return { state, error };
81+
}

0 commit comments

Comments
 (0)