mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
feat: implement unsaved changes handling in editor and sidebar
- Introduced global state management for unsaved changes and pending navigation using Jotai atoms. - Updated the editor component to sync local unsaved changes with global state and handle navigation prompts. - Enhanced sidebar functionality to check for unsaved changes before navigating to a new note. - Added cleanup logic for global state on component unmount to prevent stale data.
This commit is contained in:
parent
ee46a43afc
commit
b53b19170e
4 changed files with 108 additions and 7 deletions
|
|
@ -1,11 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import { AlertCircle, ArrowLeft, FileText, Loader2, Save } from "lucide-react";
|
import { AlertCircle, ArrowLeft, FileText, Loader2, Save } from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
|
||||||
import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor";
|
import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|
@ -76,6 +78,46 @@ export default function EditorPage() {
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
|
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
|
||||||
|
|
||||||
|
// Global state for cross-component communication
|
||||||
|
const [, setGlobalHasUnsavedChanges] = useAtom(hasUnsavedEditorChangesAtom);
|
||||||
|
const [pendingNavigation, setPendingNavigation] = useAtom(pendingEditorNavigationAtom);
|
||||||
|
|
||||||
|
// Sync local unsaved changes state with global atom
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalHasUnsavedChanges(hasUnsavedChanges);
|
||||||
|
}, [hasUnsavedChanges, setGlobalHasUnsavedChanges]);
|
||||||
|
|
||||||
|
// Cleanup global state when component unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setGlobalHasUnsavedChanges(false);
|
||||||
|
setPendingNavigation(null);
|
||||||
|
};
|
||||||
|
}, [setGlobalHasUnsavedChanges, setPendingNavigation]);
|
||||||
|
|
||||||
|
// Handle pending navigation from sidebar (e.g., when user clicks "+" to create new note)
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingNavigation) {
|
||||||
|
if (hasUnsavedChanges) {
|
||||||
|
// Show dialog to confirm navigation
|
||||||
|
setShowUnsavedDialog(true);
|
||||||
|
} else {
|
||||||
|
// No unsaved changes, navigate immediately
|
||||||
|
router.push(pendingNavigation);
|
||||||
|
setPendingNavigation(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [pendingNavigation, hasUnsavedChanges, router, setPendingNavigation]);
|
||||||
|
|
||||||
|
// Reset state when documentId changes (e.g., navigating from existing note to new note)
|
||||||
|
useEffect(() => {
|
||||||
|
setDocument(null);
|
||||||
|
setEditorContent(null);
|
||||||
|
setError(null);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
setLoading(true);
|
||||||
|
}, [documentId]);
|
||||||
|
|
||||||
// Fetch document content - DIRECT CALL TO FASTAPI
|
// Fetch document content - DIRECT CALL TO FASTAPI
|
||||||
// Skip fetching if this is a new note
|
// Skip fetching if this is a new note
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -287,7 +329,23 @@ export default function EditorPage() {
|
||||||
|
|
||||||
const handleConfirmLeave = () => {
|
const handleConfirmLeave = () => {
|
||||||
setShowUnsavedDialog(false);
|
setShowUnsavedDialog(false);
|
||||||
router.push(`/dashboard/${searchSpaceId}/researcher`);
|
// Clear global unsaved state
|
||||||
|
setGlobalHasUnsavedChanges(false);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
|
||||||
|
// If there's a pending navigation (from sidebar), use that; otherwise go back to researcher
|
||||||
|
if (pendingNavigation) {
|
||||||
|
router.push(pendingNavigation);
|
||||||
|
setPendingNavigation(null);
|
||||||
|
} else {
|
||||||
|
router.push(`/dashboard/${searchSpaceId}/researcher`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelLeave = () => {
|
||||||
|
setShowUnsavedDialog(false);
|
||||||
|
// Clear pending navigation if user cancels
|
||||||
|
setPendingNavigation(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -402,6 +460,7 @@ export default function EditorPage() {
|
||||||
)}
|
)}
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<BlockNoteEditor
|
<BlockNoteEditor
|
||||||
|
key={documentId} // Force re-mount when document changes
|
||||||
initialContent={isNewNote ? undefined : editorContent}
|
initialContent={isNewNote ? undefined : editorContent}
|
||||||
onChange={setEditorContent}
|
onChange={setEditorContent}
|
||||||
useTitleBlock={isNote}
|
useTitleBlock={isNote}
|
||||||
|
|
@ -411,7 +470,9 @@ export default function EditorPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Unsaved Changes Dialog */}
|
{/* Unsaved Changes Dialog */}
|
||||||
<AlertDialog open={showUnsavedDialog} onOpenChange={setShowUnsavedDialog}>
|
<AlertDialog open={showUnsavedDialog} onOpenChange={(open) => {
|
||||||
|
if (!open) handleCancelLeave();
|
||||||
|
}}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
|
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
|
||||||
|
|
@ -420,7 +481,7 @@ export default function EditorPage() {
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel onClick={handleCancelLeave}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleConfirmLeave}>OK</AlertDialogAction>
|
<AlertDialogAction onClick={handleConfirmLeave}>OK</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|
|
||||||
27
surfsense_web/atoms/editor/ui.atoms.ts
Normal file
27
surfsense_web/atoms/editor/ui.atoms.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
interface EditorUIState {
|
||||||
|
hasUnsavedChanges: boolean;
|
||||||
|
pendingNavigation: string | null; // URL to navigate to after user confirms
|
||||||
|
}
|
||||||
|
|
||||||
|
export const editorUIAtom = atom<EditorUIState>({
|
||||||
|
hasUnsavedChanges: false,
|
||||||
|
pendingNavigation: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derived atom for just the unsaved changes state
|
||||||
|
export const hasUnsavedEditorChangesAtom = atom(
|
||||||
|
(get) => get(editorUIAtom).hasUnsavedChanges,
|
||||||
|
(get, set, value: boolean) => {
|
||||||
|
set(editorUIAtom, { ...get(editorUIAtom), hasUnsavedChanges: value });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derived atom for pending navigation
|
||||||
|
export const pendingEditorNavigationAtom = atom(
|
||||||
|
(get) => get(editorUIAtom).pendingNavigation,
|
||||||
|
(get, set, value: string | null) => {
|
||||||
|
set(editorUIAtom, { ...get(editorUIAtom), pendingNavigation: value });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
@ -9,6 +9,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms";
|
import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms";
|
||||||
import { chatsAtom } from "@/atoms/chats/chat-query.atoms";
|
import { chatsAtom } from "@/atoms/chats/chat-query.atoms";
|
||||||
import { globalChatsQueryParamsAtom } from "@/atoms/chats/ui.atoms";
|
import { globalChatsQueryParamsAtom } from "@/atoms/chats/ui.atoms";
|
||||||
|
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { AppSidebar } from "@/components/sidebar/app-sidebar";
|
import { AppSidebar } from "@/components/sidebar/app-sidebar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -55,6 +56,10 @@ export function AppSidebarProvider({
|
||||||
const { data: chats, error: chatError, isLoading: isLoadingChats } = useAtomValue(chatsAtom);
|
const { data: chats, error: chatError, isLoading: isLoadingChats } = useAtomValue(chatsAtom);
|
||||||
const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] =
|
const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] =
|
||||||
useAtom(deleteChatMutationAtom);
|
useAtom(deleteChatMutationAtom);
|
||||||
|
|
||||||
|
// Editor state for handling unsaved changes
|
||||||
|
const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom);
|
||||||
|
const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setChatsQueryParams((prev) => ({ ...prev, search_space_id: searchSpaceId, skip: 0, limit: 4 }));
|
setChatsQueryParams((prev) => ({ ...prev, search_space_id: searchSpaceId, skip: 0, limit: 4 }));
|
||||||
|
|
@ -233,10 +238,18 @@ export function AppSidebarProvider({
|
||||||
}));
|
}));
|
||||||
}, [notesData]);
|
}, [notesData]);
|
||||||
|
|
||||||
// Handle add note
|
// Handle add note - check for unsaved changes first
|
||||||
const handleAddNote = useCallback(() => {
|
const handleAddNote = useCallback(() => {
|
||||||
router.push(`/dashboard/${searchSpaceId}/editor/new`);
|
const newNoteUrl = `/dashboard/${searchSpaceId}/editor/new`;
|
||||||
}, [router, searchSpaceId]);
|
|
||||||
|
if (hasUnsavedEditorChanges) {
|
||||||
|
// Set pending navigation - the editor will show the unsaved changes dialog
|
||||||
|
setPendingNavigation(newNoteUrl);
|
||||||
|
} else {
|
||||||
|
// No unsaved changes, navigate directly
|
||||||
|
router.push(newNoteUrl);
|
||||||
|
}
|
||||||
|
}, [router, searchSpaceId, hasUnsavedEditorChanges, setPendingNavigation]);
|
||||||
|
|
||||||
// Memoized updated navSecondary
|
// Memoized updated navSecondary
|
||||||
const updatedNavSecondary = useMemo(() => {
|
const updatedNavSecondary = useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export const chatSummary = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const chatDetails = chatSummary.extend({
|
export const chatDetails = chatSummary.extend({
|
||||||
initial_connectors: z.array(z.string()),
|
initial_connectors: z.array(z.string()).nullable().optional(),
|
||||||
messages: z.array(z.any()),
|
messages: z.array(z.any()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue