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

SNApp-notes / web / 19448920074

18 Nov 2025 12:05AM UTC coverage: 67.983% (-0.4%) from 68.4%
19448920074

push

github

jcubic
fix lint errors

461 of 703 branches covered (65.58%)

Branch coverage included in aggregate %.

3 of 3 new or added lines in 2 files covered. (100.0%)

70 existing lines in 4 files now uncovered.

830 of 1196 relevant lines covered (69.4%)

461.88 hits per line

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

86.54
/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 } from '@/types/tree';
44

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

53
/**
54
 * Props for the LeftPanel component.
55
 *
56
 * @public
57
 */
58
interface LeftPanelProps {
59
  /** Array of notes to display in tree structure */
60
  notes: NoteTreeNode[];
61
  /** Callback invoked when a note is selected */
62
  onNoteSelect: (id: number) => void;
63
  /** Callback invoked when creating a new note */
64
  onNewNote: () => void;
65
  /** Callback invoked when deleting a note */
66
  onDeleteNote: (id: number) => void;
67
  /** Callback invoked when renaming a note */
68
  onRenameNote: (id: number, name: string) => Promise<void>;
69
  /** Initial sort key from server settings (default: 'creationTime') */
70
  initialSortKey?: SortKey;
71
  /** Initial sort order from server settings (default: 'asc') */
72
  initialSortOrder?: SortOrder;
73
}
74

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

112
  // Sorting state initialized from server settings
113
  const [sortKey, setSortKey] = useState<SortKey>(initialSortKey);
1,036✔
114
  const [sortOrder, setSortOrder] = useState<SortOrder>(initialSortOrder);
1,036✔
115

116
  // Handle sort key change - update local state immediately and persist to server in background
117
  const handleSortKeyChange = useCallback((newKey: SortKey) => {
1,036✔
UNCOV
118
    setSortKey(newKey);
×
119
    // Fire-and-forget: persist to server in background without blocking UI
UNCOV
120
    updateSettings({ sortBy: newKey }).catch((error) => {
×
UNCOV
121
      console.error('Failed to save sort settings:', error);
×
122
    });
123
  }, []);
124

125
  // Handle sort order change - update local state immediately and persist to server in background
126
  const handleSortOrderChange = useCallback((newOrder: SortOrder) => {
1,036✔
UNCOV
127
    setSortOrder(newOrder);
×
128
    // Fire-and-forget: persist to server in background without blocking UI
UNCOV
129
    updateSettings({ sortOrder: newOrder }).catch((error) => {
×
UNCOV
130
      console.error('Failed to save sort settings:', error);
×
131
    });
132
  }, []);
133

134
  // Filter and prepare notes for TreeView
135
  const treeData = useMemo<NoteTreeNode[]>(() => {
1,036✔
136
    // Convert NoteTreeNode[] to Note-like array for sorting
137
    const notesWithTimestamps = notes.map((node) => ({
10,676✔
138
      noteId: node.id,
139
      name: node.name,
140
      createdAt: node.data?.createdAt || new Date(0),
10,706✔
141
      updatedAt: node.data?.updatedAt || new Date(0)
10,706✔
142
    }));
143

144
    // Apply sorting (type assertion is safe as we provide all required sortable fields)
145
    const sorted = getSortedNotes(
1,028✔
146
      notesWithTimestamps as unknown as Note[],
147
      sortKey,
148
      sortOrder
149
    );
150

151
    // Map back to NoteTreeNode[] and filter
152
    const filtered = sorted
48✔
153
      .map((sortedNote) => notes.find((n) => n.id === sortedNote.noteId)!)
71,845✔
154
      .filter((note) => note.name.toLowerCase().includes(filter.toLowerCase()));
10,667✔
155

156
    return filtered;
1,028✔
157
  }, [notes, filter, sortKey, sortOrder]);
158

159
  // Handle TreeNode selection
160
  const handleTreeNodeSelect = useCallback(
1,036✔
161
    (node: TreeNode) => {
162
      onNoteSelect((node as NoteTreeNode).id);
14✔
163
    },
164
    [onNoteSelect]
165
  );
166

167
  // Handle TreeNode rename
168
  const handleTreeNodeRename = useCallback(
1,036✔
169
    async (node: TreeNode, newName: string) => {
170
      try {
6✔
171
        await onRenameNote?.((node as NoteTreeNode).id, newName);
6✔
172
      } catch {
173
        // Error is already logged by the parent handler
174
      }
175
    },
176
    [onRenameNote]
177
  );
178

179
  // Handle TreeNode delete
180
  const handleTreeNodeDelete = useCallback((node: TreeNode) => {
1,036✔
181
    setDeleteDialog({ isOpen: true, note: node as NoteTreeNode });
4✔
182
  }, []);
183

184
  const handleConfirmDelete = useCallback(() => {
1,036✔
185
    if (deleteDialog.note) {
4!
186
      onDeleteNote?.(deleteDialog.note.id);
4✔
187
    }
188
  }, [deleteDialog.note, onDeleteNote]);
189

190
  const handleCloseDeleteDialog = useCallback(() => {
1,036✔
191
    setDeleteDialog({ isOpen: false, note: null });
4✔
192
  }, []);
193

194
  const generateName = useCallback(
1,036✔
195
    (node: NoteTreeNode) => `${node.data?.dirty ? '* ' : ''}${node.name}`,
3,348✔
196
    []
197
  );
198

199
  const generateTitle = useCallback((node: NoteTreeNode) => `/note/${node.id}`, []);
3,362✔
200

201
  return (
56✔
202
    <Box as="aside" h="100%" display="flex" flexDirection="column" bg="bg.subtle">
203
      <Stack gap={4} align="stretch" mx={4} mt={6} mb={0}>
204
        <Button colorPalette="blue" variant="solid" onClick={onNewNote}>
205
          New Note
206
        </Button>
207

208
        <Input
209
          p={3}
210
          placeholder="Filter notes..."
211
          value={filter}
212
          onChange={(e) => setFilter(e.target.value)}
2✔
213
          size="sm"
214
        />
215

216
        <SortControls
217
          sortKey={sortKey}
218
          sortOrder={sortOrder}
219
          onSortKeyChange={handleSortKeyChange}
220
          onSortOrderChange={handleSortOrderChange}
221
        />
222
      </Stack>
223

224
      <Box
225
        flex={1}
226
        mt={4}
227
        overflow="auto"
228
        w="100%"
229
        borderTop="1px solid"
230
        borderColor="border"
231
        data-testid="note-list"
232
      >
233
        {treeData.length === 0 ? (
56✔
234
          <Text textAlign="center" color="fg.muted" fontSize="sm" mt={4}>
42✔
235
            {notes.length === 0 ? 'No notes yet' : 'No matching notes'}
66✔
236
          </Text>
237
        ) : (
238
          <TreeView
239
            data={treeData}
240
            onNodeSelect={handleTreeNodeSelect}
241
            onNodeRename={handleTreeNodeRename}
242
            onNodeDelete={handleTreeNodeDelete}
243
            generateName={generateName}
244
            generateTitle={generateTitle}
245
            title=""
246
          />
247
        )}
248
      </Box>
249

250
      <ConfirmationDialog
251
        isOpen={deleteDialog.isOpen}
252
        onClose={handleCloseDeleteDialog}
253
        onConfirm={handleConfirmDelete}
254
        title="Delete Note"
255
        message={`Are you sure you want to delete "${deleteDialog.note?.name}"? This action cannot be undone.`}
256
        confirmText="Delete"
257
        cancelText="Cancel"
258
        variant="danger"
259
      />
260
    </Box>
261
  );
262
});
263

264
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