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

SNApp-notes / web / 19932163764

04 Dec 2025 02:19PM UTC coverage: 87.362% (+0.7%) from 86.69%
19932163764

push

github

jcubic
fix unit tests

610 of 729 branches covered (83.68%)

Branch coverage included in aggregate %.

1132 of 1265 relevant lines covered (89.49%)

1984.49 hits per line

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

84.13
/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
  type ReactNode
81
} from 'react';
82
import { useParams, usePathname, useRouter } from 'next/navigation';
83
import type { NoteTreeNode } from '@/types/tree';
84
import type { SaveStatus } from '@/types/notes';
85
import { useNodeSelection } from '@/hooks/useNodeSelection';
86
import { selectNode } from '@/lib/utils';
87

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

122
const NotesContext = createContext<NotesContextValue | undefined>(undefined);
93✔
123

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

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

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

232
  // list of notes that with marked selected node if it exists in URL
233
  const initSelectedNodeId = useMemo(() => parseId(params), [params]);
2,848✔
234
  const updatedNotes = useMemo(
2,848✔
235
    () => selectNode(initialNotes, initSelectedNodeId),
768✔
236
    [initialNotes, initSelectedNodeId]
237
  );
238

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

254
  // Sync notes state when initialNotes prop changes (e.g., after redirect)
255
  useEffect(() => {
2,848✔
256
    if (initialNotes.length > 0 && notes.length === 0) {
103!
257
      setNotes(initialNotes);
×
258
    }
259
  }, [initialNotes, notes.length, setNotes]);
260

261
  const markNoteDirty = updateDirtyFlag;
2,848✔
262

263
  const getSelectedNote = useCallback((): NoteTreeNode | null => {
2,848✔
264
    if (!selectedNoteId) return null;
3,238✔
265
    return notes.find((note) => note.id === selectedNoteId) || null;
2,996!
266
  }, [notes, selectedNoteId]);
267

268
  const getNote = useCallback(
2,848✔
269
    (noteId: number): NoteTreeNode | null => {
270
      return notes.find((note) => note.id === noteId) || null;
2,492!
271
    },
272
    [notes]
273
  );
274

275
  // Note selection with Next.js router
276
  const selectNote = useCallback(
2,848✔
277
    (noteId: number | null) => {
278
      // Update state immediately
279
      updateSelection(noteId);
45✔
280

281
      // Navigate using Next.js router
282
      if (noteId === null) {
45!
283
        router.push('/');
×
284
      } else {
285
        router.push(`/note/${noteId}`);
45✔
286
      }
287
    },
288
    [updateSelection, router]
289
  );
290

291
  // Sync URL to state on URL changes
292
  useEffect(() => {
2,848✔
293
    const urlNoteId = parseId(params);
146✔
294

295
    if (urlNoteId !== null) {
146✔
296
      updateSelection(urlNoteId);
64✔
297
    }
298
  }, [params, updateSelection]);
299

300
  // Auto-select first note when at root with notes available
301
  useEffect(() => {
2,848✔
302
    if (pathname === '/' && notes.length > 0 && !selectedNoteId) {
1,111✔
303
      const firstNote = notes[0];
53✔
304
      router.push(`/note/${firstNote.id}`);
53✔
305
    }
306
  }, [pathname, notes, selectedNoteId, router]);
307

308
  const value: NotesContextValue = {
2,848✔
309
    notes,
310
    selectedNoteId,
311
    saveStatus,
312
    setNotes,
313
    setSaveStatus,
314
    updateNoteContent,
315
    updateNoteName,
316
    markNoteDirty,
317
    updateNoteTimestamp,
318
    setContentHash,
319
    getSelectedNote,
320
    getNote,
321
    selectNote
322
  };
323

324
  return <NotesContext.Provider value={value}>{children}</NotesContext.Provider>;
×
325
}
326

327
function parseId(params: ReturnType<typeof useParams>) {
328
  return params?.id ? parseInt(params.id as string, 10) : null;
988✔
329
}
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