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

SNApp-notes / web / 19484980581

19 Nov 2025 12:14AM UTC coverage: 69.462% (+0.7%) from 68.807%
19484980581

push

github

jcubic
experimental fix for E2E tests

471 of 702 branches covered (67.09%)

Branch coverage included in aggregate %.

871 of 1230 relevant lines covered (70.81%)

861.5 hits per line

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

90.91
/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 Link from 'next/link';
45
import clsx from 'clsx';
46
import { FiTrash2 } from 'react-icons/fi';
47

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

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

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

116
  // Sorting state initialized from server settings
117
  const [sortKey, setSortKey] = useState<SortKey>(initialSortKey);
1,006✔
118
  const [sortOrder, setSortOrder] = useState<SortOrder>(initialSortOrder);
1,006✔
119

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

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

138
  // Filter and prepare notes for TreeView
139
  const treeData = useMemo<NoteTreeNode[]>(() => {
1,006✔
140
    // Convert NoteTreeNode[] to Note-like array for sorting
141
    const notesWithTimestamps = notes.map((node) => ({
9,796✔
142
      noteId: node.id,
143
      name: node.name,
144
      createdAt: node.data?.createdAt || new Date(0),
9,826✔
145
      updatedAt: node.data?.updatedAt || new Date(0)
9,826✔
146
    }));
147

148
    // Apply sorting (type assertion is safe as we provide all required sortable fields)
149
    const sorted = getSortedNotes(
998✔
150
      notesWithTimestamps as unknown as Note[],
151
      sortKey,
152
      sortOrder
153
    );
154

155
    // Map back to NoteTreeNode[] and filter
156
    const filtered = sorted
48✔
157
      .map((sortedNote) => notes.find((n) => n.id === sortedNote.noteId)!)
61,699✔
158
      .filter((note) => note.name.toLowerCase().includes(filter.toLowerCase()));
9,787✔
159

160
    return filtered;
998✔
161
  }, [notes, filter, sortKey, sortOrder]);
162

163
  // Handle TreeNode selection
164
  const handleTreeNodeSelect = useCallback(
1,006✔
165
    (node: TreeNodeType) => {
166
      onNoteSelect((node as NoteTreeNode).id);
13✔
167
    },
168
    [onNoteSelect]
169
  );
170

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

183
  // Handle TreeNode delete
184
  const handleTreeNodeDelete = useCallback((node: TreeNodeType) => {
1,006✔
185
    setDeleteDialog({ isOpen: true, note: node as NoteTreeNode });
4✔
186
  }, []);
187

188
  const handleConfirmDelete = useCallback(() => {
1,006✔
189
    if (deleteDialog.note) {
4!
190
      onDeleteNote?.(deleteDialog.note.id);
4✔
191
    }
192
  }, [deleteDialog.note, onDeleteNote]);
193

194
  const handleCloseDeleteDialog = useCallback(() => {
1,006✔
195
    setDeleteDialog({ isOpen: false, note: null });
4✔
196
  }, []);
197

198
  const renderTreeNode = useCallback(
1,006✔
199
    ({ node, selected, editing, hasChildren }: TreeNodeRenderProps) => {
200
      const typedNode = node as NoteTreeNode;
11,285✔
201
      const displayName = `${typedNode.data?.dirty ? '* ' : ''}${typedNode.name}`;
11,285✔
202

203
      const nodeContent = (
204
        <>
57✔
205
          <TreeNode.ExpandIcon color="fg.subtle" fontSize="xs" />
206

207
          <TreeNode.Icon
208
            color={selected ? 'currentColor' : 'fg.muted'}
11,285✔
209
            className="tree-node-icon"
210
          />
211

212
          {editing ? (
57✔
213
            <TreeNode.Edit
18✔
214
              p={2}
215
              fontSize="sm"
216
              fontWeight="medium"
217
              color="black"
218
              flex={1}
219
              size="sm"
220
              variant="outline"
221
              bg="white"
222
              _dark={{ bg: 'gray.700', color: 'white' }}
223
              _selection={{ bg: 'blue.solid', color: 'white' }}
224
              className="tree-node-input"
225
            />
226
          ) : (
227
            <>
228
              <TreeNode.Text
229
                fontSize="sm"
230
                fontWeight="medium"
231
                color="currentColor"
232
                flex={1}
233
                lineClamp={1}
234
                className="tree-node-label"
235
                title={`/note/${typedNode.id}`}
236
              >
237
                {displayName}
238
              </TreeNode.Text>
239

240
              {!hasChildren && (
11,296✔
241
                <TreeNode.DeleteButton
242
                  variant="ghost"
243
                  colorScheme="red"
244
                  size="xs"
245
                  className="tree-node-delete"
246
                  data-testid={`delete-node-${typedNode.id}`}
247
                  _hover={{ color: 'red.500' }}
248
                >
249
                  <FiTrash2 size={12} />
250
                </TreeNode.DeleteButton>
251
              )}
252
            </>
253
          )}
254
        </>
255
      );
256

257
      const wrappedContent =
258
        !hasChildren && !editing ? (
11,285✔
259
          <Link
260
            href={`/note/${typedNode.id}`}
261
            style={{
262
              display: 'contents',
263
              textDecoration: 'none',
264
              color: 'inherit'
265
            }}
266
            onClick={() => onNoteSelect(typedNode.id)}
11✔
267
          >
268
            {nodeContent}
269
          </Link>
270
        ) : (
271
          nodeContent
272
        );
273

274
      return (
57✔
275
        <TreeNode.Content
276
          bg={selected ? 'blue.solid' : 'transparent'}
11,285✔
277
          color={selected ? 'white' : 'fg'}
11,285✔
278
          _hover={{ bg: selected ? 'blue.solid' : 'bg.muted' }}
11,285✔
279
          borderRadius="md"
280
          px={2}
281
          py={2}
282
          outline={selected && !hasChildren ? '2px solid' : 'none'}
23,472✔
283
          outlineColor="blue.500"
284
          outlineOffset="2px"
285
          className={clsx('tree-item', 'tree-node', {
286
            'tree-node-selected': selected,
287
            'tree-node-expandable': hasChildren,
288
            'tree-node-leaf': !hasChildren
289
          })}
290
        >
291
          {wrappedContent}
292
        </TreeNode.Content>
293
      );
294
    },
295
    [onNoteSelect]
296
  );
297

298
  return (
56✔
299
    <Box as="aside" h="100%" display="flex" flexDirection="column" bg="bg.subtle">
300
      <Stack gap={4} align="stretch" mx={4} mt={6} mb={0}>
301
        <Button colorPalette="blue" variant="solid" onClick={onNewNote}>
302
          New Note
303
        </Button>
304

305
        <Input
306
          p={3}
307
          placeholder="Filter notes..."
308
          value={filter}
309
          onChange={(e) => setFilter(e.target.value)}
2✔
310
          size="sm"
311
        />
312

313
        <SortControls
314
          sortKey={sortKey}
315
          sortOrder={sortOrder}
316
          onSortKeyChange={handleSortKeyChange}
317
          onSortOrderChange={handleSortOrderChange}
318
        />
319
      </Stack>
320

321
      <Box
322
        flex={1}
323
        mt={4}
324
        overflow="auto"
325
        w="100%"
326
        borderTop="1px solid"
327
        borderColor="border"
328
        data-testid="note-list"
329
      >
330
        {treeData.length === 0 ? (
56✔
331
          <Text textAlign="center" color="fg.muted" fontSize="sm" mt={4}>
44✔
332
            {notes.length === 0 ? 'No notes yet' : 'No matching notes'}
68✔
333
          </Text>
334
        ) : (
335
          <TreeView
336
            data={treeData}
337
            render={renderTreeNode}
338
            onNodeSelect={handleTreeNodeSelect}
339
            onNodeRename={handleTreeNodeRename}
340
            onNodeDelete={handleTreeNodeDelete}
341
            title=""
342
          />
343
        )}
344
      </Box>
345

346
      <ConfirmationDialog
347
        isOpen={deleteDialog.isOpen}
348
        onClose={handleCloseDeleteDialog}
349
        onConfirm={handleConfirmDelete}
350
        title="Delete Note"
351
        message={`Are you sure you want to delete "${deleteDialog.note?.name}"? This action cannot be undone.`}
352
        confirmText="Delete"
353
        cancelText="Cancel"
354
        variant="danger"
355
      />
356
    </Box>
357
  );
358
});
359

360
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