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 { 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>
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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