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

SNApp-notes / web / 20411192302

21 Dec 2025 02:24PM UTC coverage: 86.434% (+0.3%) from 86.171%
20411192302

push

github

jcubic
fix unit tests

715 of 870 branches covered (82.18%)

Branch coverage included in aggregate %.

1413 of 1592 relevant lines covered (88.76%)

2063.49 hits per line

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

85.92
/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
  useRef,
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 { selectNode } from '@/lib/utils';
88
import { cleanupEditorStates } from '@/lib/localStorage';
89

90
/**
91
 * NotesContext value interface exposing all notes state and operations.
92
 *
93
 * @interface NotesContextValue
94
 * @property {NoteTreeNode[]} notes - Array of all notes with selection and dirty flags
95
 * @property {number | null} selectedNoteId - Currently selected note ID (null if none)
96
 * @property {SaveStatus} saveStatus - Current save state ('saving' | 'saved' | 'error' | 'unsaved')
97
 * @property {(notes: NoteTreeNode[] | ((prev: NoteTreeNode[]) => NoteTreeNode[])) => void} setNotes - Update entire notes array
98
 * @property {(status: SaveStatus) => void} setSaveStatus - Update save status
99
 * @property {(noteId: number, content: string) => void} updateNoteContent - Update note content (marks as dirty)
100
 * @property {(noteId: number, name: string) => void} updateNoteName - Update note name (marks as dirty)
101
 * @property {(noteId: number, dirty: boolean) => void} markNoteDirty - Set note's dirty flag
102
 * @property {(noteId: number, updatedAt: Date) => void} updateNoteTimestamp - Update note's updatedAt timestamp
103
 * @property {(noteId: number, content: string) => void} setSavedContentHash - Set saved content hash for undo detection
104
 * @property {() => NoteTreeNode | null} getSelectedNote - Get currently selected note object
105
 * @property {(noteId: number) => NoteTreeNode | null} getNote - Get note by ID
106
 * @property {(noteId: number | null) => void} selectNote - Select note and navigate to URL
107
 */
108
interface NotesContextValue {
109
  notes: NoteTreeNode[];
110
  selectedNoteId: number | null;
111
  saveStatus: SaveStatus;
112
  setNotes: (notes: NoteTreeNode[] | ((prev: NoteTreeNode[]) => NoteTreeNode[])) => void;
113
  setSaveStatus: (status: SaveStatus) => void;
114
  updateNoteContent: (noteId: number, content: string) => void;
115
  updateNoteName: (noteId: number, name: string) => void;
116
  markNoteDirty: (noteId: number, dirty: boolean) => void;
117
  updateNoteTimestamp: (noteId: number, updatedAt: Date) => void;
118
  setContentHash: (noteId: number, content: string) => void;
119
  getSelectedNote: () => NoteTreeNode | null;
120
  getNote: (noteId: number) => NoteTreeNode | null;
121
  selectNote: (noteId: number | null) => void;
122
}
123

124
const NotesContext = createContext<NotesContextValue | undefined>(undefined);
105✔
125

126
/**
127
 * Hook to access NotesContext value in child components.
128
 *
129
 * @hook
130
 * @returns {NotesContextValue} Notes context value with state and operations
131
 * @throws {Error} If used outside of NotesProvider
132
 *
133
 * @remarks
134
 * Must be used within a component wrapped by `<NotesProvider>`.
135
 * Throws error if context is undefined (not within provider).
136
 *
137
 * @example
138
 * ```tsx
139
 * function NotesList() {
140
 *   const { notes, selectNote } = useNotesContext();
141
 *
142
 *   return (
143
 *     <ul>
144
 *       {notes.map(note => (
145
 *         <li key={note.id} onClick={() => selectNote(note.id)}>
146
 *           {note.name}
147
 *         </li>
148
 *       ))}
149
 *     </ul>
150
 *   );
151
 * }
152
 * ```
153
 */
154
export function useNotesContext() {
105✔
155
  const context = useContext(NotesContext);
9,481✔
156
  if (context === undefined) {
9,481!
157
    throw new Error('useNotesContext must be used within a NotesProvider');
×
158
  }
159
  return context;
9,481✔
160
}
161

162
/**
163
 * Props for NotesProvider component.
164
 *
165
 * @interface NotesProviderProps
166
 * @property {ReactNode} children - Child components to wrap with context
167
 * @property {NoteTreeNode[]} [initialNotes=[]] - Initial notes array (from server)
168
 * @property {number | null} [initialSelectedNoteId=null] - Initial selected note ID
169
 */
170
interface NotesProviderProps {
171
  children: ReactNode;
172
  initialNotes?: NoteTreeNode[];
173
}
174

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

234
  // list of notes that with marked selected node if it exists in URL
235
  const initSelectedNodeId = useMemo(() => parseId(params), [params]);
3,146✔
236
  const updatedNotes = useMemo(
3,146✔
237
    () => selectNode(initialNotes, initSelectedNodeId),
716✔
238
    [initialNotes, initSelectedNodeId]
239
  );
240

241
  // Use the hook that manages all the state
242
  const {
243
    notes,
244
    selectedNoteId,
245
    saveStatus,
246
    setNotes,
247
    setSaveStatus,
248
    updateSelection,
249
    updateDirtyFlag,
250
    updateNoteContent,
251
    updateNoteName,
252
    updateNoteTimestamp,
253
    setContentHash
254
  } = useNodeSelection(updatedNotes, initSelectedNodeId);
3,146✔
255

256
  // Track if cleanup has been performed
257
  const cleanupPerformedRef = useRef(false);
3,146✔
258

259
  // Clean up editor states on mount (remove cursor/scroll for non-selected notes)
260
  useEffect(() => {
3,146✔
261
    // Only run cleanup once on mount
262
    if (cleanupPerformedRef.current) {
148✔
263
      return;
55✔
264
    }
265

266
    cleanupPerformedRef.current = true;
93✔
267
    cleanupEditorStates(selectedNoteId);
93✔
268
  }, [selectedNoteId]);
269

270
  // Sync notes state when initialNotes prop changes (e.g., after redirect)
271
  useEffect(() => {
3,146✔
272
    if (initialNotes.length > 0 && notes.length === 0) {
137!
273
      setNotes(initialNotes);
×
274
    }
275
  }, [initialNotes, notes.length, setNotes]);
276

277
  const markNoteDirty = updateDirtyFlag;
3,146✔
278

279
  const getSelectedNote = useCallback((): NoteTreeNode | null => {
3,146✔
280
    if (!selectedNoteId) return null;
3,638✔
281
    return notes.find((note) => note.id === selectedNoteId) || null;
3,912!
282
  }, [notes, selectedNoteId]);
283

284
  const getNote = useCallback(
3,146✔
285
    (noteId: number): NoteTreeNode | null => {
286
      return notes.find((note) => note.id === noteId) || null;
3,358!
287
    },
288
    [notes]
289
  );
290

291
  // Note selection with Next.js router
292
  const selectNote = useCallback(
3,146✔
293
    (noteId: number | null) => {
294
      // Update state immediately
295
      updateSelection(noteId);
51✔
296

297
      // Navigate using Next.js router
298
      if (noteId === null) {
51!
299
        router.push('/');
×
300
      } else {
301
        router.push(`/note/${noteId}`);
51✔
302
      }
303
    },
304
    [updateSelection, router]
305
  );
306

307
  // Sync URL to state on URL changes
308
  useEffect(() => {
3,146✔
309
    const urlNoteId = parseId(params);
182✔
310

311
    if (urlNoteId !== null) {
182✔
312
      updateSelection(urlNoteId);
77✔
313
    }
314
  }, [params, updateSelection]);
315

316
  // Auto-select first note when at root with notes available
317
  useEffect(() => {
3,146✔
318
    if (pathname === '/' && notes.length > 0 && !selectedNoteId) {
1,289✔
319
      const firstNote = notes[0];
60✔
320
      router.push(`/note/${firstNote.id}`);
60✔
321
    }
322
  }, [pathname, notes, selectedNoteId, router]);
323

324
  const value: NotesContextValue = {
3,146✔
325
    notes,
326
    selectedNoteId,
327
    saveStatus,
328
    setNotes,
329
    setSaveStatus,
330
    updateNoteContent,
331
    updateNoteName,
332
    markNoteDirty,
333
    updateNoteTimestamp,
334
    setContentHash,
335
    getSelectedNote,
336
    getNote,
337
    selectNote
338
  };
339

340
  return <NotesContext.Provider value={value}>{children}</NotesContext.Provider>;
×
341
}
342

343
function parseId(params: ReturnType<typeof useParams>) {
344
  return params?.id ? parseInt(params.id as string, 10) : null;
984✔
345
}
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