Disable profile, admin, and bot save buttons until form values change

Hop-State: A_06FP8S80Z94HDTEW2N8K9CR
Hop-Proposal: R_06FP8S72ZG4GM11PWB730NG
Hop-Task: T_06FP8RK1EC7TAB7JVYYNAF8
Hop-Attempt: AT_06FP8RK1EE6RYZPXK3WD3Z0
This commit is contained in:
2026-07-14 23:09:28 -07:00
committed by Hop
parent 1db2933758
commit c6dd550f10
5 changed files with 95 additions and 10 deletions
+9 -3
View File
@@ -9,6 +9,7 @@ import { useToast } from '@/lib/contexts/ToastContext';
import { useAccentColor } from '@/lib/contexts/AccentColorContext'; import { useAccentColor } from '@/lib/contexts/AccentColorContext';
import { refreshStorageSession } from '@/lib/storage/client'; import { refreshStorageSession } from '@/lib/storage/client';
import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload'; import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
import { hasUnsavedChanges } from '@/lib/forms/dirty-state';
export default function AdminPage() { export default function AdminPage() {
const { showToast } = useToast(); const { showToast } = useToast();
@@ -28,6 +29,8 @@ export default function AdminPage() {
turnstileSiteKey: '', turnstileSiteKey: '',
turnstileSecretKey: '', turnstileSecretKey: '',
}); });
const savedNodeSettingsRef = useRef<typeof nodeSettings | null>(null);
const nodeSettingsChanged = hasUnsavedChanges(nodeSettings, savedNodeSettingsRef.current);
const [savingSettings, setSavingSettings] = useState(false); const [savingSettings, setSavingSettings] = useState(false);
const [isUploadingBanner, setIsUploadingBanner] = useState(false); const [isUploadingBanner, setIsUploadingBanner] = useState(false);
const [isCheckingBannerStorage, setIsCheckingBannerStorage] = useState(false); const [isCheckingBannerStorage, setIsCheckingBannerStorage] = useState(false);
@@ -55,7 +58,7 @@ export default function AdminPage() {
try { try {
const res = await fetch('/api/node'); const res = await fetch('/api/node');
const data = await res.json(); const data = await res.json();
setNodeSettings({ const loadedSettings = {
name: data.name || '', name: data.name || '',
description: data.description || '', description: data.description || '',
longDescription: data.longDescription || '', longDescription: data.longDescription || '',
@@ -67,7 +70,9 @@ export default function AdminPage() {
isNsfw: data.isNsfw || false, isNsfw: data.isNsfw || false,
turnstileSiteKey: data.turnstileSiteKey || '', turnstileSiteKey: data.turnstileSiteKey || '',
turnstileSecretKey: data.turnstileSecretKey || '', turnstileSecretKey: data.turnstileSecretKey || '',
}); };
setNodeSettings(loadedSettings);
savedNodeSettingsRef.current = loadedSettings;
} catch { } catch {
// error // error
} finally { } finally {
@@ -91,6 +96,7 @@ export default function AdminPage() {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (res.ok) { if (res.ok) {
savedNodeSettingsRef.current = payload;
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (data.node) { if (data.node) {
window.dispatchEvent(new CustomEvent('synapsis:node-updated', { detail: data.node })); window.dispatchEvent(new CustomEvent('synapsis:node-updated', { detail: data.node }));
@@ -607,7 +613,7 @@ export default function AdminPage() {
</div> </div>
<div style={{ paddingTop: '8px' }}> <div style={{ paddingTop: '8px' }}>
<button className="btn btn-primary" onClick={() => handleSaveSettings()} disabled={savingSettings}> <button className="btn btn-primary" onClick={() => handleSaveSettings()} disabled={savingSettings || !nodeSettingsChanged}>
{savingSettings ? 'Saving...' : 'Save Settings'} {savingSettings ? 'Saving...' : 'Save Settings'}
</button> </button>
</div> </div>
+17 -5
View File
@@ -8,12 +8,13 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons'; import { ArrowLeftIcon } from '@/components/Icons';
import { UserStorageImageUpload } from '@/components/UserStorageImageUpload'; import { UserStorageImageUpload } from '@/components/UserStorageImageUpload';
import { Bot, Sparkles, Rss, Clock, Trash2 } from 'lucide-react'; import { Bot, Sparkles, Rss, Clock, Trash2 } from 'lucide-react';
import { hasUnsavedChanges } from '@/lib/forms/dirty-state';
interface ContentSource { interface ContentSource {
id?: string; id?: string;
@@ -66,8 +67,10 @@ export default function EditBotPage() {
postingFrequency: 'every_4_hours', postingFrequency: 'every_4_hours',
customIntervalMinutes: 240, customIntervalMinutes: 240,
}); });
const savedFormDataRef = useRef<typeof formData | null>(null);
const [sources, setSources] = useState<ContentSource[]>([]); const [sources, setSources] = useState<ContentSource[]>([]);
const savedSourcesRef = useRef<ContentSource[] | null>(null);
const [newSource, setNewSource] = useState<ContentSource>({ const [newSource, setNewSource] = useState<ContentSource>({
type: 'rss', type: 'rss',
url: '', url: '',
@@ -78,6 +81,9 @@ export default function EditBotPage() {
newsQuery: '', newsQuery: '',
}); });
const [sourcesToDelete, setSourcesToDelete] = useState<string[]>([]); const [sourcesToDelete, setSourcesToDelete] = useState<string[]>([]);
const botChanged = hasUnsavedChanges(formData, savedFormDataRef.current)
|| hasUnsavedChanges(sources, savedSourcesRef.current)
|| sourcesToDelete.length > 0;
useEffect(() => { useEffect(() => {
fetchBot(); fetchBot();
@@ -129,7 +135,7 @@ export default function EditBotPage() {
} }
} }
setFormData({ const loadedFormData = {
name: bot.name || '', name: bot.name || '',
handle: bot.handle || '', handle: bot.handle || '',
bio: bot.bio || '', bio: bot.bio || '',
@@ -143,12 +149,17 @@ export default function EditBotPage() {
autonomousMode: bot.autonomousMode || false, autonomousMode: bot.autonomousMode || false,
postingFrequency, postingFrequency,
customIntervalMinutes, customIntervalMinutes,
}); };
setFormData(loadedFormData);
savedFormDataRef.current = loadedFormData;
let loadedSources: ContentSource[] = [];
if (sourcesRes.ok) { if (sourcesRes.ok) {
const sourcesData = await sourcesRes.json(); const sourcesData = await sourcesRes.json();
setSources(sourcesData.sources || []); loadedSources = sourcesData.sources || [];
} }
setSources(loadedSources);
savedSourcesRef.current = loadedSources;
} catch (err) { } catch (err) {
console.error('Failed to fetch bot:', err); console.error('Failed to fetch bot:', err);
setError('Failed to load bot data'); setError('Failed to load bot data');
@@ -266,6 +277,7 @@ export default function EditBotPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!botChanged) return;
setSaving(true); setSaving(true);
setError(''); setError('');
@@ -994,7 +1006,7 @@ export default function EditBotPage() {
<button <button
key="submit-button" key="submit-button"
type="submit" type="submit"
disabled={saving} disabled={saving || !botChanged}
className="btn btn-primary" className="btn btn-primary"
> >
{saving ? 'Saving...' : 'Save Changes'} {saving ? 'Saving...' : 'Save Changes'}
+12 -2
View File
@@ -12,6 +12,7 @@ import { Rocket, MoreHorizontal, Mail } from 'lucide-react';
import { useFormattedHandle } from '@/lib/utils/handle'; import { useFormattedHandle } from '@/lib/utils/handle';
import { Bot } from 'lucide-react'; import { Bot } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { hasUnsavedChanges } from '@/lib/forms/dirty-state';
import { signedAPI } from '@/lib/api/signed-fetch'; import { signedAPI } from '@/lib/api/signed-fetch';
import { AvatarImage } from '@/components/AvatarImage'; import { AvatarImage } from '@/components/AvatarImage';
@@ -114,6 +115,15 @@ export default function ProfilePage() {
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [mediaSaveStatus, setMediaSaveStatus] = useState<Partial<Record<ProfileMediaField, 'saving' | 'saved'>>>({}); const [mediaSaveStatus, setMediaSaveStatus] = useState<Partial<Record<ProfileMediaField, 'saving' | 'saved'>>>({});
const savedProfileForm = user ? {
displayName: user.displayName || '',
bio: user.bio || '',
avatarUrl: user.avatarUrl || '',
headerUrl: user.headerUrl || '',
website: user.website || '',
} : null;
const profileChanged = hasUnsavedChanges(profileForm, savedProfileForm);
const isSavingProfileMedia = Object.values(mediaSaveStatus).includes('saving');
const [isBlocked, setIsBlocked] = useState(false); const [isBlocked, setIsBlocked] = useState(false);
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
useEffect(() => { useEffect(() => {
@@ -400,7 +410,7 @@ export default function ProfilePage() {
}; };
const handleSaveProfile = async () => { const handleSaveProfile = async () => {
if (!isOwnProfile) return; if (!isOwnProfile || !profileChanged) return;
if (!isIdentityUnlocked) { if (!isIdentityUnlocked) {
setSaveError('Session expired. Please log in again.'); setSaveError('Session expired. Please log in again.');
@@ -846,7 +856,7 @@ export default function ProfilePage() {
<button className="btn btn-ghost" onClick={() => setIsEditing(false)} disabled={isSaving}> <button className="btn btn-ghost" onClick={() => setIsEditing(false)} disabled={isSaving}>
Cancel Cancel
</button> </button>
<button className="btn btn-primary" onClick={handleSaveProfile} disabled={isSaving}> <button className="btn btn-primary" onClick={handleSaveProfile} disabled={isSaving || isSavingProfileMedia || !profileChanged}>
{isSaving ? 'Saving...' : 'Save'} {isSaving ? 'Saving...' : 'Save'}
</button> </button>
</div> </div>
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { hasUnsavedChanges } from './dirty-state';
describe('hasUnsavedChanges', () => {
it('stays clean until saved values have loaded', () => {
expect(hasUnsavedChanges({ name: '' }, null)).toBe(false);
});
it('detects changes and becomes clean again when reverted', () => {
const saved = { name: 'Synapsis', isNsfw: false };
expect(hasUnsavedChanges(saved, saved)).toBe(false);
expect(hasUnsavedChanges({ ...saved, name: 'New name' }, saved)).toBe(true);
expect(hasUnsavedChanges({ ...saved }, saved)).toBe(false);
});
it('detects nested array changes such as bot sources', () => {
const saved = [{ id: 'source-1', config: { active: true } }];
expect(hasUnsavedChanges(saved, saved)).toBe(false);
expect(hasUnsavedChanges([...saved, { id: 'source-2', config: { active: true } }], saved)).toBe(true);
});
});
+33
View File
@@ -0,0 +1,33 @@
function valuesEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (Array.isArray(left) && Array.isArray(right)) {
return left.length === right.length
&& left.every((value, index) => valuesEqual(value, right[index]));
}
if (
left !== null
&& right !== null
&& typeof left === 'object'
&& typeof right === 'object'
) {
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const leftKeys = Object.keys(leftRecord);
const rightKeys = Object.keys(rightRecord);
return leftKeys.length === rightKeys.length
&& leftKeys.every((key) =>
Object.prototype.hasOwnProperty.call(rightRecord, key)
&& valuesEqual(leftRecord[key], rightRecord[key])
);
}
return false;
}
export function hasUnsavedChanges<T>(current: T, saved: T | null): boolean {
return saved !== null && !valuesEqual(current, saved);
}