• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

SNApp-notes / web / 22591761104

02 Mar 2026 07:17PM UTC coverage: 85.852% (-0.2%) from 86.069%
22591761104

push

github

jcubic
fix E2E tests

778 of 959 branches covered (81.13%)

Branch coverage included in aggregate %.

14 of 16 new or added lines in 5 files covered. (87.5%)

51 existing lines in 6 files now uncovered.

1534 of 1734 relevant lines covered (88.47%)

2852.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

76.74
/src/components/notes/NotesContext.tsx
1
/**
2
 * @module components/notes/NotesContext
3
 * @description React Context for managing global notes state with URL synchronization.
4
 * Provides notes tree, selection state, save status, and CRUD operations to all child components.
5
 *
6
 * @dependencies
7
 * - `next/navigation` - usePathname and useRouter for URL-based routing
8
 * - `@/hooks/useNodeSelection` - Core state management hook for notes
9
 * - `@/types/tree` - NoteTreeNode type definition
10
 * - `@/types/notes` - SaveStatus type definition
11
 *
12
 * @remarks
13
 * **Features:**
14
 * - Global notes tree state (flat array of notes with selection flags)
15
 * - Selected note tracking with URL synchronization
16
 * - Save status management (saving, saved, error, unsaved)
17
 * - Note CRUD operations (update content, update name, mark dirty)
18
 * - Auto-select first note when navigating to root
19
 * - Next.js router integration for note navigation
20
 *
21
 * **URL Synchronization:**
22
 * - `/note/:id` routes automatically sync to context state
23
 * - `selectNote(id)` updates both state and URL
24
 * - Browser back/forward buttons work correctly
25
 * - Initial note selection from URL on mount
26
 *
27
 * **State Management:**
28
 * - Uses `useNodeSelection` hook for core logic
29
 * - Context wraps hook state for global access
30
 * - Initial state from server-side props (SSR-friendly)
31
 * - Automatically syncs when initial props change
32
 *
33
 * **Performance:**
34
 * - Memoized callbacks to prevent unnecessary re-renders
35
 * - Selective updates (only changed notes re-render)
36
 * - useCallback for all functions to stabilize references
37
 *
38
 * @example
39
 * ```tsx
40
 * import { NotesProvider, useNotesContext } from '@/components/notes/NotesContext';
41
 *
42
 * // Wrap app with provider
43
 * export default function NotesLayout({ children, initialNotes }) {
44
 *   return (
45
 *     <NotesProvider initialNotes={initialNotes}>
46
 *       {children}
47
 *     </NotesProvider>
48
 *   );
49
 * }
50
 *
51
 * // Use context in child component
52
 * function NoteEditor() {
53
 *   const {
54
 *     notes,
55
 *     selectedNoteId,
56
 *     updateNoteContent,
57
 *     saveStatus
58
 *   } = useNotesContext();
59
 *
60
 *   const selectedNote = notes.find(n => n.id === selectedNoteId);
61
 *
62
 *   return (
63
 *     <Editor
64
 *       value={selectedNote?.data?.content || ''}
65
 *       onChange={(content) => updateNoteContent(selectedNoteId!, content)}
66
 *       saveStatus={saveStatus}
67
 *     />
68
 *   );
69
 * }
70
 * ```
71
 */
72
'use client';
73

74
import {
75
  createContext,
76
  useContext,
77
  useEffect,
78
  useCallback,
79
  useMemo,
80
  useState,
81
  type ReactNode
82
} from 'react';
83
import { useParams, usePathname, useRouter } from 'next/navigation';
84
import type { NoteTreeNode } from '@/types/tree';
85
import type { SaveStatus } from '@/types/notes';
86
import { useNodeSelection } from '@/hooks/useNodeSelection';
87
import { clearEditorState } from '@/lib/localStorage';
88

89
/**
90
 * NotesContext value interface exposing all notes state and operations.
91
 *
92
 * @interface NotesContextValue
93
 * @property {NoteTreeNode[]} notes - Array of all notes with selection and dirty flags
94
 * @property {number | null} selectedNoteId - Currently selected note ID (null if none)
95
 * @property {SaveStatus} saveStatus - Current save state ('saving' | 'saved' | 'error' | 'unsaved')
96
 * @property {(notes: NoteTreeNode[] | ((prev: NoteTreeNode[]) => NoteTreeNode[])) => void} setNotes - Update entire notes array
97
 * @property {(status: SaveStatus) => void} setSaveStatus - Update save status
98
 * @property {(noteId: number, content: string) => void} updateNoteContent - Update note content (marks as dirty)
99
 * @property {(noteId: number, name: string) => void} updateNoteName - Update note name (marks as dirty)
100
 * @property {(noteId: number, dirty: boolean) => void} markNoteDirty - Set note's dirty flag
101
 * @property {(noteId: number, updatedAt: Date) => void} updateNoteTimestamp - Update note's updatedAt timestamp
102
 * @property {(noteId: number, content: string) => void} setSavedContentHash - Set saved content hash for undo detection
103
 * @property {() => NoteTreeNode | null} getSelectedNote - Get currently selected note object
104
 * @property {(noteId: number) => NoteTreeNode | null} getNote - Get note by ID
105
 * @property {(noteId: number | null) => void} selectNote - Select note and navigate to URL
106
 * @property {number | null} newNoteId - ID of newly created note in edit mode (null if none)
107
 * @property {(noteId: number | null) => void} setNewNoteId - Set new note ID for immediate edit mode
108
 * @property {boolean} pendingSave - Whether a save is queued waiting for note creation to complete
109
 * @property {() => void} requestSave - Request a save (queues if note creation is in progress)
110
 * @property {() => void} executePendingSave - Execute any pending save (called after note creation)
111
 */
112
interface NotesContextValue {
113
  notes: NoteTreeNode[];
114
  selectedNoteId: number | null;
115
  saveStatus: SaveStatus;
116
  setNotes: (notes: NoteTreeNode[] | ((prev: NoteTreeNode[]) => NoteTreeNode[])) => void;
117
  setSaveStatus: (status: SaveStatus) => void;
118
  updateNoteContent: (noteId: number, content: string) => void;
119
  updateNoteName: (noteId: number, name: string) => void;
120
  markNoteDirty: (noteId: number, dirty: boolean) => void;
121
  updateNoteTimestamp: (noteId: number, updatedAt: Date) => void;
122
  setContentHash: (noteId: number, content: string) => void;
123
  getSelectedNote: () => NoteTreeNode | null;
124
  getNote: (noteId: number) => NoteTreeNode | null;
125
  selectNote: (noteId: number | null) => void;
126
  newNoteId: number | null;
127
  setNewNoteId: (noteId: number | null) => void;
128
  isCreatingNote: boolean;
129
  setIsCreatingNote: (isCreating: boolean) => void;
130
  pendingSave: boolean;
131
  requestSave: () => void;
132
  executePendingSave: () => void;
133
}
134

135
const NotesContext = createContext<NotesContextValue | undefined>(undefined);
99✔
136

137
/**
138
 * Hook to access NotesContext value in child components.
139
 *
140
 * @hook
141
 * @returns {NotesContextValue} Notes context value with state and operations
142
 * @throws {Error} If used outside of NotesProvider
143
 *
144
 * @remarks
145
 * Must be used within a component wrapped by `<NotesProvider>`.
146
 * Throws error if context is undefined (not within provider).
147
 *
148
 * @example
149
 * ```tsx
150
 * function NotesList() {
151
 *   const { notes, selectNote } = useNotesContext();
152
 *
153
 *   return (
154
 *     <ul>
155
 *       {notes.map(note => (
156
 *         <li key={note.id} onClick={() => selectNote(note.id)}>
157
 *           {note.name}
158
 *         </li>
159
 *       ))}
160
 *     </ul>
161
 *   );
162
 * }
163
 * ```
164
 */
165
export function useNotesContext() {
99✔
166
  const context = useContext(NotesContext);
9,790✔
167
  if (context === undefined) {
9,790!
UNCOV
168
    throw new Error('useNotesContext must be used within a NotesProvider');
×
169
  }
170
  return context;
9,790✔
171
}
172

173
/**
174
 * Props for NotesProvider component.
175
 *
176
 * @interface NotesProviderProps
177
 * @property {ReactNode} children - Child components to wrap with context
178
 * @property {NoteTreeNode[]} [initialNotes=[]] - Initial notes array (from server)
179
 * @property {number | null} [initialSelectedNoteId=null] - Initial selected note ID
180
 */
181
interface NotesProviderProps {
182
  children: ReactNode;
183
  initialNotes?: NoteTreeNode[];
184
}
185

186
/**
187
 * Notes context provider with URL synchronization and auto-selection.
188
 *
189
 * @component
190
 * @param {NotesProviderProps} props - Provider configuration
191
 * @returns {JSX.Element} Provider wrapping children
192
 *
193
 * @remarks
194
 * **Initialization:**
195
 * - Accepts `initialNotes` from server-side props (SSR)
196
 * - Accepts `initialSelectedNoteId` to pre-select a note
197
 * - Syncs initial state when props change (e.g., after navigation)
198
 *
199
 * **URL Synchronization:**
200
 * - Watches pathname for `/note/:id` pattern
201
 * - Updates selection state when URL changes (e.g., browser back/forward)
202
 * - `selectNote()` updates both state and URL via Next.js router
203
 *
204
 * **Auto-selection:**
205
 * - If at root (`/`) with notes available and none selected, auto-selects first note
206
 * - Automatically navigates to `/note/:id` after auto-selection
207
 * - Prevents empty state when notes exist
208
 *
209
 * **State Management:**
210
 * - Uses `useNodeSelection` hook for core state logic
211
 * - Exposes state and operations via context value
212
 * - Memoized callbacks to prevent unnecessary re-renders
213
 *
214
 * **Operations:**
215
 * - `updateNoteContent`: Updates content and marks as dirty
216
 * - `updateNoteName`: Updates name and marks as dirty
217
 * - `markNoteDirty`: Sets dirty flag (true = unsaved changes)
218
 * - `selectNote`: Updates selection and navigates to URL
219
 * - `getSelectedNote`: Returns currently selected note object
220
 * - `getNote`: Returns note by ID
221
 *
222
 * @example
223
 * ```tsx
224
 * // In layout component
225
 * export default async function NotesLayout({ children }) {
226
 *   const notes = await getNotes();
227
 *   const selectedId = getSelectedNoteIdFromUrl();
228
 *
229
 *   return (
230
 *     <NotesProvider
231
 *       initialNotes={notes}
232
 *       initialSelectedNoteId={selectedId}
233
 *     >
234
 *       {children}
235
 *     </NotesProvider>
236
 *   );
237
 * }
238
 * ```
239
 */
240
export function NotesProvider({ children, initialNotes = [] }: NotesProviderProps) {
99!
241
  const params = useParams();
3,340✔
242
  const pathname = usePathname();
3,340✔
243
  const router = useRouter();
3,340✔
244

245
  // list of notes that with marked selected node if it exists in URL
246
  const initSelectedNodeId = useMemo(() => parseId(params), [params]);
3,340✔
247

248
  // Use the hook that manages all the state
249
  const {
250
    notes,
251
    selectedNoteId,
252
    saveStatus,
253
    setNotes,
254
    setSaveStatus,
255
    updateSelection,
256
    updateDirtyFlag,
257
    updateNoteContent,
258
    updateNoteName,
259
    updateNoteTimestamp,
260
    setContentHash
261
  } = useNodeSelection(initialNotes, initSelectedNodeId);
3,340✔
262

263
  // Track newly created note for immediate edit mode
264
  const [newNoteId, setNewNoteId] = useState<number | null>(null);
3,340✔
265

266
  // Track if note creation is in progress (prevents save during optimistic update)
267
  const [isCreatingNote, setIsCreatingNote] = useState<boolean>(false);
3,340✔
268

269
  // Track if a save is pending (queued during note creation)
270
  const [pendingSave, setPendingSave] = useState<boolean>(false);
3,340✔
271

272
  // Request a save - if note creation is in progress, queue it
273
  const requestSave = useCallback(() => {
3,340✔
UNCOV
274
    if (isCreatingNote) {
×
UNCOV
275
      console.log('Save queued: note creation in progress');
×
UNCOV
276
      setPendingSave(true);
×
277
    }
278
  }, [isCreatingNote]);
279

280
  // Execute pending save (called from LeftPanel after note creation completes)
281
  // This is a no-op in context - the actual save is triggered via effect in content
282
  const executePendingSave = useCallback(() => {
3,340✔
283
    // Clear the pending flag - the actual save will be triggered
284
    // by an effect watching this flag in the content component
UNCOV
285
    setPendingSave(false);
×
286
  }, []);
287

288
  // Sync notes state when initialNotes prop changes (e.g., after redirect)
289
  useEffect(() => {
3,340✔
290
    if (initialNotes.length > 0 && notes.length === 0) {
128!
UNCOV
291
      setNotes(initialNotes);
×
292
    }
293
  }, [initialNotes, notes.length, setNotes]);
294

295
  const markNoteDirty = updateDirtyFlag;
3,340✔
296

297
  const getSelectedNote = useCallback((): NoteTreeNode | null => {
3,340✔
298
    if (!selectedNoteId) return null;
3,698✔
299
    return notes.find((note) => note.id === selectedNoteId) || null;
3,302!
300
  }, [notes, selectedNoteId]);
301

302
  const getNote = useCallback(
3,340✔
303
    (noteId: number): NoteTreeNode | null => {
304
      return notes.find((note) => note.id === noteId) || null;
2,785!
305
    },
306
    [notes]
307
  );
308

309
  // Note selection with Next.js router
310
  const selectNote = useCallback(
3,340✔
311
    (noteId: number | null) => {
312
      // If already on this note, refresh server data to ensure content is up-to-date
313
      // (avoids stale content after page reload without resetting editor state)
314
      if (noteId === selectedNoteId) {
10!
NEW
315
        router.refresh();
×
NEW
316
        return;
×
317
      }
318

319
      // Clear editor state when switching notes
320
      // This ensures cursor/scroll position is NOT restored on note switch
321
      // (only on page refresh)
322
      clearEditorState();
10✔
323

324
      // Update state immediately
325
      updateSelection(noteId);
10✔
326

327
      // Navigate using Next.js router
328
      // Use pathname without query params to clear line parameter
329
      if (noteId === null) {
10!
UNCOV
330
        router.push('/', { scroll: false });
×
331
      } else {
332
        router.push(`/note/${noteId}`, { scroll: false });
10✔
333
      }
334
    },
335
    [updateSelection, router, selectedNoteId]
336
  );
337

338
  // Sync URL to state on URL changes
339
  useEffect(() => {
3,340✔
340
    const urlNoteId = parseId(params);
1,357✔
341

342
    // Always update selection when URL has a note ID, but only if:
343
    // 1. The note exists in our notes array (prevents issues when URL changes before notes array is updated)
344
    // 2. The note is different from currently selected (optimization)
345
    if (urlNoteId !== null) {
1,357✔
346
      if (urlNoteId !== selectedNoteId) {
1,212✔
347
        const noteExists = notes.some((n) => n.id === urlNoteId);
46✔
348
        if (noteExists) {
43!
349
          updateSelection(urlNoteId);
43✔
350
        }
351
      }
352
    }
353
  }, [params, updateSelection, notes, selectedNoteId]);
354

355
  // Auto-select first note when at root with notes available
356
  // Skip when note creation is in progress to prevent race conditions
357
  useEffect(() => {
3,340✔
358
    if (pathname === '/' && notes.length > 0 && !selectedNoteId && !isCreatingNote) {
1,331✔
359
      const firstNote = notes[0];
75✔
360
      router.push(`/note/${firstNote.id}`);
75✔
361
    }
362
  }, [pathname, notes, selectedNoteId, router, isCreatingNote]);
363

364
  const value: NotesContextValue = {
3,340✔
365
    notes,
366
    selectedNoteId,
367
    saveStatus,
368
    setNotes,
369
    setSaveStatus,
370
    updateNoteContent,
371
    updateNoteName,
372
    markNoteDirty,
373
    updateNoteTimestamp,
374
    setContentHash,
375
    getSelectedNote,
376
    getNote,
377
    selectNote,
378
    newNoteId,
379
    setNewNoteId,
380
    isCreatingNote,
381
    setIsCreatingNote,
382
    pendingSave,
383
    requestSave,
384
    executePendingSave
385
  };
386

UNCOV
387
  return <NotesContext.Provider value={value}>{children}</NotesContext.Provider>;
×
388
}
389

390
function parseId(params: ReturnType<typeof useParams>) {
391
  return params?.id ? parseInt(params.id as string, 10) : null;
2,233✔
392
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc