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

SNApp-notes / web / 21097437569

17 Jan 2026 04:30PM UTC coverage: 85.98% (-0.5%) from 86.452%
21097437569

push

github

jcubic
attempt to fix the root cause of broken E2E tests

768 of 941 branches covered (81.62%)

Branch coverage included in aggregate %.

7 of 7 new or added lines in 1 file covered. (100.0%)

41 existing lines in 5 files now uncovered.

1495 of 1691 relevant lines covered (88.41%)

1801.57 hits per line

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

90.79
/src/components/notes/LeftPanel.tsx
1
/**
2
 * Left sidebar panel for note management and navigation.
3
 *
4
 * @remarks
5
 * Dependencies: Chakra UI v3, React, TreeView, ConfirmationDialog
6
 *
7
 * **Features:**
8
 * - Note list display with tree structure
9
 * - Real-time note filtering
10
 * - Note sorting by name, creation time, or update time
11
 * - New note creation button
12
 * - Note rename functionality
13
 * - Note deletion with confirmation dialog
14
 * - Dirty state indicator (asterisk prefix)
15
 * - Empty state handling
16
 * - Persistent sort settings via database (server actions)
17
 *
18
 * **Performance:**
19
 * - Memoized component to prevent unnecessary re-renders
20
 * - Memoized tree data filtering and sorting
21
 * - useCallback hooks for event handlers
22
 * - Server-side initial sorting eliminates flicker
23
 *
24
 * @example
25
 * ```tsx
26
 * <LeftPanel
27
 *   notes={userNotes}
28
 *   onNoteSelect={(id) => router.push(`/note/${id}`)}
29
 *   onNewNote={handleCreateNote}
30
 *   onDeleteNote={handleDeleteNote}
31
 *   onRenameNote={handleRenameNote}
32
 *   initialSortKey="creationTime"
33
 *   initialSortOrder="asc"
34
 * />
35
 * ```
36
 *
37
 * @public
38
 */
39
'use client';
40

41
import { useState, useMemo, memo, useCallback } from 'react';
42
import { Box, Button, Input, Stack, Text } from '@chakra-ui/react';
43
import type { NoteTreeNode, TreeNode as TreeNodeType } from '@/types/tree';
44
import clsx from 'clsx';
45
import { FiTrash2 } from 'react-icons/fi';
46

47
import { TreeView, TreeNode } from '@/components/TreeView';
48
import type { TreeNodeRenderProps } from '@/components/TreeView';
49
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog';
50
import { getSortedNotes } from '@/lib/sort-notes';
51
import { SortKey, SortOrder } from '@/types/notes';
52
import { SortControls } from '@/components/notes/SortControls';
53
import { updateSettings } from '@/app/actions/settings';
54
import type { Note } from '@/lib/prisma';
55

56
/**
57
 * Props for the LeftPanel component.
58
 *
59
 * @public
60
 */
61
interface LeftPanelProps {
62
  /** Array of notes to display in tree structure */
63
  notes: NoteTreeNode[];
64
  /** Callback invoked when a note is selected */
65
  onNoteSelect: (id: number) => void;
66
  /** Callback invoked when creating a new note */
67
  onNewNote: () => void;
68
  /** Callback invoked when deleting a note */
69
  onDeleteNote: (id: number) => void;
70
  /** Callback invoked when renaming a note */
71
  onRenameNote: (id: number, name: string) => Promise<void>;
72
  /** Callback invoked when filter changes (to clear newNoteId) */
73
  onFilterChange?: () => void;
74
  /** Callback invoked when edit mode ends (e.g., after renaming a new note) */
75
  onEditEnd?: () => void;
76
  /** Initial sort key from server settings (default: 'creationTime') */
77
  initialSortKey?: SortKey;
78
  /** Initial sort order from server settings (default: 'asc') */
79
  initialSortOrder?: SortOrder;
80
  /** ID of newly created note that should start in edit mode */
81
  newNoteId?: number | null;
82
}
83

84
/**
85
 * Renders the left sidebar panel with note list and management controls.
86
 *
87
 * @param props - Component props
88
 * @param props.notes - Array of note tree nodes
89
 * @param props.onNoteSelect - Handler for note selection
90
 * @param props.onNewNote - Handler for new note creation
91
 * @param props.onDeleteNote - Handler for note deletion
92
 * @param props.onRenameNote - Async handler for note renaming
93
 * @param props.initialSortKey - Initial sort key from server
94
 * @param props.initialSortOrder - Initial sort order from server
95
 * @returns Memoized left panel component
96
 *
97
 * @remarks
98
 * Manages local state for filtering, sorting, and delete confirmation.
99
 * Filters notes by name (case-insensitive) and sorts by selected criteria.
100
 * Shows asterisk prefix for notes with unsaved changes.
101
 * Sort settings persist in database via server actions.
102
 * Client-side sorting provides immediate feedback before server update.
103
 *
104
 * @public
105
 */
106
const LeftPanel = memo(function LeftPanel({
106✔
107
  notes,
108
  onNoteSelect,
109
  onNewNote,
110
  onDeleteNote,
111
  onRenameNote,
112
  onFilterChange,
113
  onEditEnd,
114
  initialSortKey = SortKey.CreationTime,
66✔
115
  initialSortOrder = SortOrder.Ascending,
66✔
116
  newNoteId = null
66✔
117
}: LeftPanelProps) {
118
  const [filter, setFilter] = useState('');
2,512✔
119
  const [deleteDialog, setDeleteDialog] = useState<{
2,512✔
120
    isOpen: boolean;
121
    note: NoteTreeNode | null;
122
  }>({ isOpen: false, note: null });
123

124
  // Sorting state initialized from server settings
125
  const [sortKey, setSortKey] = useState<SortKey>(initialSortKey);
2,512✔
126
  const [sortOrder, setSortOrder] = useState<SortOrder>(initialSortOrder);
2,512✔
127

128
  // Handle sort key change - update local state immediately and persist to server in background
129
  const handleSortKeyChange = useCallback((newKey: SortKey) => {
2,512✔
130
    setSortKey(newKey);
×
131
    // Fire-and-forget: persist to server in background without blocking UI
132
    updateSettings({ sortBy: newKey }).catch((error) => {
×
133
      console.error('Failed to save sort settings:', error);
×
134
    });
135
  }, []);
136

137
  // Handle sort order change - update local state immediately and persist to server in background
138
  const handleSortOrderChange = useCallback((newOrder: SortOrder) => {
2,512✔
UNCOV
139
    setSortOrder(newOrder);
×
140
    // Fire-and-forget: persist to server in background without blocking UI
UNCOV
141
    updateSettings({ sortOrder: newOrder }).catch((error) => {
×
UNCOV
142
      console.error('Failed to save sort settings:', error);
×
143
    });
144
  }, []);
145

146
  // Filter and prepare notes for TreeView
147
  const treeData = useMemo<NoteTreeNode[]>(() => {
2,512✔
148
    // Convert NoteTreeNode[] to Note-like array for sorting
149
    const notesWithTimestamps = notes.map((node) => ({
26,463✔
150
      noteId: node.id,
151
      name: node.name,
152
      createdAt: node.data?.createdAt || new Date(0),
26,473✔
153
      updatedAt: node.data?.updatedAt || new Date(0)
26,473✔
154
    }));
155

156
    // Apply sorting (type assertion is safe as we provide all required sortable fields)
157
    const sorted = getSortedNotes(
2,503✔
158
      notesWithTimestamps as unknown as Note[],
159
      sortKey,
160
      sortOrder
161
    );
162

163
    // Map back to NoteTreeNode[] and filter
164
    // Always include newNoteId note regardless of filter (for new notes)
165
    return sorted
57✔
166
      .map((sortedNote) => notes.find((n) => n.id === sortedNote.noteId)!)
173,647✔
167
      .filter(
168
        (note) =>
169
          note.id === newNoteId || note.name.toLowerCase().includes(filter.toLowerCase())
26,461✔
170
      );
171
  }, [notes, filter, sortKey, sortOrder, newNoteId]);
172

173
  // Handle TreeNode selection
174
  const handleTreeNodeSelect = useCallback(
2,512✔
175
    (node: TreeNodeType) => {
176
      onNoteSelect((node as NoteTreeNode).id);
8✔
177
    },
178
    [onNoteSelect]
179
  );
180

181
  // Handle TreeNode rename
182
  const handleTreeNodeRename = useCallback(
2,512✔
183
    async (node: TreeNodeType, newName: string) => {
184
      try {
7✔
185
        await onRenameNote?.((node as NoteTreeNode).id, newName);
7✔
186
      } catch {
187
        // Error is already logged by the parent handler
188
      }
189
    },
190
    [onRenameNote]
191
  );
192

193
  // Handle TreeNode delete
194
  const handleTreeNodeDelete = useCallback((node: TreeNodeType) => {
2,512✔
195
    setDeleteDialog({ isOpen: true, note: node as NoteTreeNode });
4✔
196
  }, []);
197

198
  // Handle filter input change - notify parent to clear newNoteId
199
  const handleFilterInputChange = useCallback(
2,512✔
200
    (e: React.ChangeEvent<HTMLInputElement>) => {
201
      setFilter(e.target.value);
4✔
202
      onFilterChange?.();
4✔
203
    },
204
    [onFilterChange]
205
  );
206

207
  const handleConfirmDelete = useCallback(() => {
2,512✔
208
    if (deleteDialog.note) {
4!
209
      onDeleteNote?.(deleteDialog.note.id);
4✔
210
    }
211
  }, [deleteDialog.note, onDeleteNote]);
212

213
  const handleCloseDeleteDialog = useCallback(() => {
2,512✔
214
    setDeleteDialog({ isOpen: false, note: null });
4✔
215
  }, []);
216

217
  const renderTreeNode = useCallback(
2,512✔
218
    ({ node, selected, editing, hasChildren }: TreeNodeRenderProps) => {
219
      const typedNode = node as NoteTreeNode;
28,677✔
220
      const displayName = `${typedNode.data?.dirty ? '* ' : ''}${typedNode.name}`;
28,677✔
221

222
      const nodeContent = (
223
        <>
73✔
224
          <TreeNode.ExpandIcon color="fg.subtle" fontSize="xs" />
225

226
          <TreeNode.Icon
227
            color={selected ? 'currentColor' : 'fg.muted'}
28,677✔
228
            className="tree-node-icon"
229
          />
230

231
          {editing ? (
73✔
232
            <TreeNode.Edit
444✔
233
              p={2}
234
              fontSize="sm"
235
              fontWeight="medium"
236
              color="black"
237
              flex={1}
238
              size="sm"
239
              variant="outline"
240
              bg="white"
241
              _dark={{ bg: 'gray.700', color: 'white' }}
242
              _selection={{ bg: 'blue.solid', color: 'white' }}
243
              className="tree-node-input"
244
            />
245
          ) : (
246
            <>
247
              <TreeNode.Text
248
                fontSize="sm"
249
                fontWeight="medium"
250
                color="currentColor"
251
                flex={1}
252
                lineClamp={1}
253
                className="tree-node-label"
254
                title={`/note/${typedNode.id}`}
255
              >
256
                {displayName}
257
              </TreeNode.Text>
258

259
              {!hasChildren && (
28,262✔
260
                <TreeNode.DeleteButton
261
                  variant="ghost"
262
                  colorScheme="red"
263
                  size="xs"
264
                  className="tree-node-delete"
265
                  data-testid={`delete-node-${typedNode.id}`}
266
                  _hover={{ color: 'red.500' }}
267
                >
268
                  <FiTrash2 size={12} />
269
                </TreeNode.DeleteButton>
270
              )}
271
            </>
272
          )}
273
        </>
274
      );
275

276
      // Don't wrap in Link - handle navigation via onClick on TreeNode.Content
277
      // This preserves double-click to edit functionality
278
      const wrappedContent = nodeContent;
28,677✔
279

280
      return (
73✔
281
        <TreeNode.Content
282
          bg={selected ? 'blue.solid' : 'transparent'}
28,677✔
283
          color={selected ? 'white' : 'fg'}
28,677✔
284
          _hover={{ bg: selected ? 'blue.solid' : 'bg.muted' }}
28,677✔
285
          borderRadius="md"
286
          px={2}
287
          py={2}
288
          outline={selected && !hasChildren ? '2px solid' : 'none'}
59,684✔
289
          outlineColor="blue.500"
290
          outlineOffset="2px"
291
          className={clsx('tree-item', 'tree-node', {
292
            'tree-node-selected': selected,
293
            'tree-node-expandable': hasChildren,
294
            'tree-node-leaf': !hasChildren
295
          })}
296
        >
297
          {wrappedContent}
298
        </TreeNode.Content>
299
      );
300
    },
301
    []
302
  );
303

304
  return (
66✔
305
    <Box as="aside" h="100%" display="flex" flexDirection="column" bg="bg.subtle">
306
      <Stack gap={4} align="stretch" mx={4} mt={6} mb={0}>
307
        <Button colorPalette="blue" variant="solid" onClick={onNewNote}>
308
          New Note
309
        </Button>
310

311
        <Input
312
          p={3}
313
          placeholder="Filter notes..."
314
          value={filter}
315
          onChange={handleFilterInputChange}
316
          size="sm"
317
        />
318

319
        <SortControls
320
          sortKey={sortKey}
321
          sortOrder={sortOrder}
322
          onSortKeyChange={handleSortKeyChange}
323
          onSortOrderChange={handleSortOrderChange}
324
        />
325
      </Stack>
326

327
      <Box
328
        flex={1}
329
        mt={4}
330
        overflow="auto"
331
        w="100%"
332
        borderTop="1px solid"
333
        borderColor="border"
334
        data-testid="note-list"
335
      >
336
        {treeData.length === 0 ? (
66✔
337
          <Text textAlign="center" color="fg.muted" fontSize="sm" mt={4}>
84✔
338
            {notes.length === 0 ? 'No notes yet' : 'No matching notes'}
108✔
339
          </Text>
340
        ) : (
341
          <TreeView
342
            data={treeData}
343
            render={renderTreeNode}
344
            onNodeSelect={handleTreeNodeSelect}
345
            onNodeRename={handleTreeNodeRename}
346
            onNodeDelete={handleTreeNodeDelete}
347
            onEditEnd={onEditEnd}
348
            editingNodeId={newNoteId}
349
            title=""
350
          />
351
        )}
352
      </Box>
353

354
      <ConfirmationDialog
355
        isOpen={deleteDialog.isOpen}
356
        onClose={handleCloseDeleteDialog}
357
        onConfirm={handleConfirmDelete}
358
        title="Delete Note"
359
        message={`Are you sure you want to delete "${deleteDialog.note?.name}"? This action cannot be undone.`}
360
        confirmText="Delete"
361
        cancelText="Cancel"
362
        variant="danger"
363
      />
364
    </Box>
365
  );
366
});
367

368
export default LeftPanel;
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