• 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

88.31
/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 } 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
  /** ID of currently selected note, for syncing tree selection highlight */
83
  selectedNoteId?: number | null;
84
}
85

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

127
  // Sorting state initialized from server settings
128
  const [sortKey, setSortKey] = useState<SortKey>(initialSortKey);
2,726✔
129
  const [sortOrder, setSortOrder] = useState<SortOrder>(initialSortOrder);
2,726✔
130

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

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

149
  // Filter and prepare notes for TreeView
150
  const treeData = useMemo<NoteTreeNode[]>(() => {
2,726✔
151
    // Convert NoteTreeNode[] to Note-like array for sorting
152
    const notesWithTimestamps = notes.map((node) => ({
29,503✔
153
      noteId: node.id,
154
      name: node.name,
155
      createdAt: node.data?.createdAt || new Date(0),
29,501!
156
      updatedAt: node.data?.updatedAt || new Date(0)
29,501!
157
    }));
158

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

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

176
  // Handle TreeNode selection
177
  const handleTreeNodeSelect = useCallback(
2,726✔
178
    (node: NoteTreeNode) => {
179
      onNoteSelect(node.id);
12✔
180
    },
181
    [onNoteSelect]
182
  );
183

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

196
  // Handle TreeNode delete
197
  const handleTreeNodeDelete = useCallback((node: NoteTreeNode) => {
2,726✔
198
    setDeleteDialog({ isOpen: true, note: node });
4✔
199
  }, []);
200

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

210
  const handleConfirmDelete = useCallback(() => {
2,726✔
211
    if (deleteDialog.note) {
4!
212
      onDeleteNote?.(deleteDialog.note.id);
4✔
213
    }
214
  }, [deleteDialog.note, onDeleteNote]);
215

216
  const handleCloseDeleteDialog = useCallback(() => {
2,726✔
217
    setDeleteDialog({ isOpen: false, note: null });
4✔
218
  }, []);
219

220
  const renderTreeNode = useCallback(
2,726✔
221
    ({ node, selected, editing, hasChildren }: TreeNodeRenderProps) => {
222
      const typedNode = node as NoteTreeNode;
49,372✔
223
      const displayName = `${typedNode.data?.dirty ? '* ' : ''}${typedNode.name}`;
49,372✔
224

225
      const nodeContent = (
226
        <>
92✔
227
          <TreeNode.ExpandIcon color="fg.subtle" fontSize="xs" />
228

229
          <TreeNode.Icon
230
            color={selected ? 'currentColor' : 'fg.muted'}
49,372✔
231
            className="tree-node-icon"
232
          />
233

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

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

279
      // Don't wrap in Link - handle navigation via onClick on TreeNode.Content
280
      // This preserves double-click to edit functionality
281
      const wrappedContent = nodeContent;
49,372✔
282

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

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

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

322
        <SortControls
323
          sortKey={sortKey}
324
          sortOrder={sortOrder}
325
          onSortKeyChange={handleSortKeyChange}
326
          onSortOrderChange={handleSortOrderChange}
327
        />
328
      </Stack>
329

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

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

372
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