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:
@@ -9,6 +9,7 @@ import { useToast } from '@/lib/contexts/ToastContext';
|
||||
import { useAccentColor } from '@/lib/contexts/AccentColorContext';
|
||||
import { refreshStorageSession } from '@/lib/storage/client';
|
||||
import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
|
||||
import { hasUnsavedChanges } from '@/lib/forms/dirty-state';
|
||||
|
||||
export default function AdminPage() {
|
||||
const { showToast } = useToast();
|
||||
@@ -28,6 +29,8 @@ export default function AdminPage() {
|
||||
turnstileSiteKey: '',
|
||||
turnstileSecretKey: '',
|
||||
});
|
||||
const savedNodeSettingsRef = useRef<typeof nodeSettings | null>(null);
|
||||
const nodeSettingsChanged = hasUnsavedChanges(nodeSettings, savedNodeSettingsRef.current);
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
const [isUploadingBanner, setIsUploadingBanner] = useState(false);
|
||||
const [isCheckingBannerStorage, setIsCheckingBannerStorage] = useState(false);
|
||||
@@ -55,7 +58,7 @@ export default function AdminPage() {
|
||||
try {
|
||||
const res = await fetch('/api/node');
|
||||
const data = await res.json();
|
||||
setNodeSettings({
|
||||
const loadedSettings = {
|
||||
name: data.name || '',
|
||||
description: data.description || '',
|
||||
longDescription: data.longDescription || '',
|
||||
@@ -67,7 +70,9 @@ export default function AdminPage() {
|
||||
isNsfw: data.isNsfw || false,
|
||||
turnstileSiteKey: data.turnstileSiteKey || '',
|
||||
turnstileSecretKey: data.turnstileSecretKey || '',
|
||||
});
|
||||
};
|
||||
setNodeSettings(loadedSettings);
|
||||
savedNodeSettingsRef.current = loadedSettings;
|
||||
} catch {
|
||||
// error
|
||||
} finally {
|
||||
@@ -91,6 +96,7 @@ export default function AdminPage() {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) {
|
||||
savedNodeSettingsRef.current = payload;
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data.node) {
|
||||
window.dispatchEvent(new CustomEvent('synapsis:node-updated', { detail: data.node }));
|
||||
@@ -607,7 +613,7 @@ export default function AdminPage() {
|
||||
</div>
|
||||
|
||||
<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'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeftIcon } from '@/components/Icons';
|
||||
import { UserStorageImageUpload } from '@/components/UserStorageImageUpload';
|
||||
import { Bot, Sparkles, Rss, Clock, Trash2 } from 'lucide-react';
|
||||
import { hasUnsavedChanges } from '@/lib/forms/dirty-state';
|
||||
|
||||
interface ContentSource {
|
||||
id?: string;
|
||||
@@ -66,8 +67,10 @@ export default function EditBotPage() {
|
||||
postingFrequency: 'every_4_hours',
|
||||
customIntervalMinutes: 240,
|
||||
});
|
||||
const savedFormDataRef = useRef<typeof formData | null>(null);
|
||||
|
||||
const [sources, setSources] = useState<ContentSource[]>([]);
|
||||
const savedSourcesRef = useRef<ContentSource[] | null>(null);
|
||||
const [newSource, setNewSource] = useState<ContentSource>({
|
||||
type: 'rss',
|
||||
url: '',
|
||||
@@ -78,6 +81,9 @@ export default function EditBotPage() {
|
||||
newsQuery: '',
|
||||
});
|
||||
const [sourcesToDelete, setSourcesToDelete] = useState<string[]>([]);
|
||||
const botChanged = hasUnsavedChanges(formData, savedFormDataRef.current)
|
||||
|| hasUnsavedChanges(sources, savedSourcesRef.current)
|
||||
|| sourcesToDelete.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
fetchBot();
|
||||
@@ -129,7 +135,7 @@ export default function EditBotPage() {
|
||||
}
|
||||
}
|
||||
|
||||
setFormData({
|
||||
const loadedFormData = {
|
||||
name: bot.name || '',
|
||||
handle: bot.handle || '',
|
||||
bio: bot.bio || '',
|
||||
@@ -143,12 +149,17 @@ export default function EditBotPage() {
|
||||
autonomousMode: bot.autonomousMode || false,
|
||||
postingFrequency,
|
||||
customIntervalMinutes,
|
||||
});
|
||||
};
|
||||
setFormData(loadedFormData);
|
||||
savedFormDataRef.current = loadedFormData;
|
||||
|
||||
let loadedSources: ContentSource[] = [];
|
||||
if (sourcesRes.ok) {
|
||||
const sourcesData = await sourcesRes.json();
|
||||
setSources(sourcesData.sources || []);
|
||||
loadedSources = sourcesData.sources || [];
|
||||
}
|
||||
setSources(loadedSources);
|
||||
savedSourcesRef.current = loadedSources;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch bot:', err);
|
||||
setError('Failed to load bot data');
|
||||
@@ -266,6 +277,7 @@ export default function EditBotPage() {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!botChanged) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
|
||||
@@ -994,7 +1006,7 @@ export default function EditBotPage() {
|
||||
<button
|
||||
key="submit-button"
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
disabled={saving || !botChanged}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Rocket, MoreHorizontal, Mail } from 'lucide-react';
|
||||
import { useFormattedHandle } from '@/lib/utils/handle';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { hasUnsavedChanges } from '@/lib/forms/dirty-state';
|
||||
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||
import { AvatarImage } from '@/components/AvatarImage';
|
||||
|
||||
@@ -114,6 +115,15 @@ export default function ProfilePage() {
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
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 [showMenu, setShowMenu] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -400,7 +410,7 @@ export default function ProfilePage() {
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (!isOwnProfile) return;
|
||||
if (!isOwnProfile || !profileChanged) return;
|
||||
|
||||
if (!isIdentityUnlocked) {
|
||||
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}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleSaveProfile} disabled={isSaving}>
|
||||
<button className="btn btn-primary" onClick={handleSaveProfile} disabled={isSaving || isSavingProfileMedia || !profileChanged}>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user