From 7b9d52d97045f5b70d6a7ee17ffc250de8f63d55 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 14 Feb 2026 21:14:16 +0100 Subject: [PATCH] Add create note dialog with type selector (M5 Task 1) --- e2e/create-note.spec.ts | 57 ++++++++++++ src/App.tsx | 65 +++++++++++++- src/components/CreateNoteDialog.css | 131 ++++++++++++++++++++++++++++ src/components/CreateNoteDialog.tsx | 100 +++++++++++++++++++++ src/components/NoteList.css | 28 ++++++ src/components/NoteList.test.tsx | 26 +++--- src/components/NoteList.tsx | 10 ++- src/mock-tauri.ts | 5 ++ 8 files changed, 405 insertions(+), 17 deletions(-) create mode 100644 e2e/create-note.spec.ts create mode 100644 src/components/CreateNoteDialog.css create mode 100644 src/components/CreateNoteDialog.tsx diff --git a/e2e/create-note.spec.ts b/e2e/create-note.spec.ts new file mode 100644 index 00000000..7d6aa61b --- /dev/null +++ b/e2e/create-note.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test' + +test('clicking + button opens create note dialog', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + // Click the + button + await page.click('.note-list__add-btn') + await page.waitForTimeout(200) + + // Dialog should be visible + await expect(page.locator('.create-dialog')).toBeVisible() + await expect(page.locator('.create-dialog__title')).toHaveText('Create New Note') + + await page.screenshot({ path: 'test-results/create-dialog.png', fullPage: true }) +}) + +test('create a new note via dialog', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + // Open dialog + await page.click('.note-list__add-btn') + await page.waitForTimeout(200) + + // Type a title + await page.fill('.create-dialog__input', 'My Test Note') + + // Select "Project" type + await page.click('.create-dialog__type-btn:text("Project")') + + // Click Create + await page.click('.create-dialog__btn--create') + await page.waitForTimeout(300) + + // Dialog should close + await expect(page.locator('.create-dialog')).not.toBeVisible() + + // New note should appear in the list and be opened in editor + await expect(page.locator('.note-list__item:has-text("My Test Note")').first()).toBeVisible() + await expect(page.locator('.editor__tab--active:has-text("My Test Note")')).toBeVisible() + + await page.screenshot({ path: 'test-results/create-note-result.png', fullPage: true }) +}) + +test('escape closes create dialog', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + await page.click('.note-list__add-btn') + await page.waitForTimeout(200) + await expect(page.locator('.create-dialog')).toBeVisible() + + await page.keyboard.press('Escape') + await page.waitForTimeout(100) + await expect(page.locator('.create-dialog')).not.toBeVisible() +}) diff --git a/src/App.tsx b/src/App.tsx index 0273b0de..f4f70af9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,8 @@ import { NoteList } from './components/NoteList' import { Editor } from './components/Editor' import { Inspector } from './components/Inspector' import { ResizeHandle } from './components/ResizeHandle' -import { isTauri, mockInvoke } from './mock-tauri' +import { CreateNoteDialog, type NoteType } from './components/CreateNoteDialog' +import { isTauri, mockInvoke, addMockEntry } from './mock-tauri' import type { VaultEntry, SidebarSelection, GitCommit } from './types' import './App.css' @@ -25,6 +26,7 @@ function App() { const [inspectorCollapsed, setInspectorCollapsed] = useState(false) const [allContent, setAllContent] = useState>({}) const [gitHistory, setGitHistory] = useState([]) + const [showCreateDialog, setShowCreateDialog] = useState(false) useEffect(() => { const loadVault = async () => { @@ -152,6 +154,60 @@ function App() { } }, [entries, handleSelectNote]) + const handleCreateNote = useCallback(async (title: string, type: NoteType) => { + // Build file path: type determines folder + const typeToFolder: Record = { + Note: 'note', + Project: 'project', + Experiment: 'experiment', + Responsibility: 'responsibility', + Procedure: 'procedure', + Person: 'person', + Event: 'event', + Topic: 'topic', + } + const folder = typeToFolder[type] || 'note' + const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + const path = `/Users/luca/Laputa/${folder}/${slug}.md` + const now = Math.floor(Date.now() / 1000) + + const newEntry: VaultEntry = { + path, + filename: `${slug}.md`, + title, + isA: type, + aliases: [], + belongsTo: [], + relatedTo: [], + status: type === 'Topic' || type === 'Person' ? null : 'Active', + owner: null, + cadence: null, + modifiedAt: now, + fileSize: 0, + } + + const frontmatter = [ + '---', + `title: ${title}`, + `is_a: ${type}`, + ...(newEntry.status ? [`status: ${newEntry.status}`] : []), + '---', + ].join('\n') + const content = `${frontmatter}\n\n# ${title}\n\n` + + if (isTauri()) { + // TODO: Add Tauri command for creating notes + } else { + addMockEntry(newEntry, content) + } + + setEntries((prev) => [newEntry, ...prev]) + setAllContent((prev) => ({ ...prev, [path]: content })) + + // Open the new note + handleSelectNote(newEntry) + }, [handleSelectNote]) + const handleSidebarResize = useCallback((delta: number) => { setSidebarWidth((w) => Math.max(150, Math.min(400, w + delta))) }, []) @@ -174,7 +230,7 @@ function App() {
- + setShowCreateDialog(true)} />
@@ -202,6 +258,11 @@ function App() { onNavigate={handleNavigateWikilink} />
+ setShowCreateDialog(false)} + onCreate={handleCreateNote} + /> ) } diff --git a/src/components/CreateNoteDialog.css b/src/components/CreateNoteDialog.css new file mode 100644 index 00000000..be4f51e8 --- /dev/null +++ b/src/components/CreateNoteDialog.css @@ -0,0 +1,131 @@ +.create-dialog__overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.create-dialog { + background: #1e1e3a; + border: 1px solid #2a2a4a; + border-radius: 12px; + padding: 24px; + width: 420px; + max-width: 90vw; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.create-dialog__title { + margin: 0 0 20px 0; + font-size: 16px; + font-weight: 600; + color: #e0e0e0; +} + +.create-dialog__field { + margin-bottom: 16px; +} + +.create-dialog__label { + display: block; + font-size: 12px; + color: #888; + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.create-dialog__input { + width: 100%; + padding: 8px 12px; + background: #16162a; + border: 1px solid #2a2a4a; + border-radius: 6px; + color: #e0e0e0; + font-size: 14px; + outline: none; + box-sizing: border-box; +} + +.create-dialog__input:focus { + border-color: #4a9eff; +} + +.create-dialog__input::placeholder { + color: #555; +} + +.create-dialog__types { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.create-dialog__type-btn { + padding: 4px 12px; + font-size: 12px; + border: 1px solid #2a2a4a; + border-radius: 14px; + background: transparent; + color: #888; + cursor: pointer; + transition: all 0.15s; +} + +.create-dialog__type-btn:hover { + background: #2a2a4a; + color: #e0e0e0; +} + +.create-dialog__type-btn--active { + background: #4a9eff; + color: #fff; + border-color: #4a9eff; +} + +.create-dialog__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 20px; +} + +.create-dialog__btn { + padding: 6px 16px; + font-size: 13px; + border-radius: 6px; + border: none; + cursor: pointer; + font-weight: 500; +} + +.create-dialog__btn--cancel { + background: transparent; + color: #888; + border: 1px solid #2a2a4a; +} + +.create-dialog__btn--cancel:hover { + background: #2a2a4a; + color: #e0e0e0; +} + +.create-dialog__btn--create { + background: #4a9eff; + color: #fff; +} + +.create-dialog__btn--create:hover { + background: #3a8eef; +} + +.create-dialog__btn--create:disabled { + opacity: 0.4; + cursor: not-allowed; +} diff --git a/src/components/CreateNoteDialog.tsx b/src/components/CreateNoteDialog.tsx new file mode 100644 index 00000000..5d1aa608 --- /dev/null +++ b/src/components/CreateNoteDialog.tsx @@ -0,0 +1,100 @@ +import { useState, useRef, useEffect } from 'react' +import './CreateNoteDialog.css' + +const NOTE_TYPES = [ + 'Note', + 'Project', + 'Experiment', + 'Responsibility', + 'Procedure', + 'Person', + 'Event', + 'Topic', +] as const + +export type NoteType = (typeof NOTE_TYPES)[number] + +interface CreateNoteDialogProps { + open: boolean + onClose: () => void + onCreate: (title: string, type: NoteType) => void +} + +export function CreateNoteDialog({ open, onClose, onCreate }: CreateNoteDialogProps) { + const [title, setTitle] = useState('') + const [type, setType] = useState('Note') + const inputRef = useRef(null) + + useEffect(() => { + if (open) { + setTitle('') + setType('Note') + // Focus input after render + setTimeout(() => inputRef.current?.focus(), 50) + } + }, [open]) + + // Close on Escape + useEffect(() => { + if (!open) return + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', handleKey) + return () => window.removeEventListener('keydown', handleKey) + }, [open, onClose]) + + if (!open) return null + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + const trimmed = title.trim() + if (!trimmed) return + onCreate(trimmed, type) + onClose() + } + + return ( +
+
e.stopPropagation()}> +

Create New Note

+
+
+ + setTitle(e.target.value)} + /> +
+
+ +
+ {NOTE_TYPES.map((t) => ( + + ))} +
+
+
+ + +
+
+
+
+ ) +} diff --git a/src/components/NoteList.css b/src/components/NoteList.css index 2656cce6..ac4249b4 100644 --- a/src/components/NoteList.css +++ b/src/components/NoteList.css @@ -21,6 +21,12 @@ font-weight: 600; } +.note-list__header-right { + display: flex; + align-items: center; + gap: 8px; +} + .note-list__count { font-size: 12px; color: #888; @@ -29,6 +35,28 @@ border-radius: 10px; } +.note-list__add-btn { + width: 24px; + height: 24px; + border: none; + border-radius: 6px; + background: #2a2a4a; + color: #888; + font-size: 16px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: all 0.15s; +} + +.note-list__add-btn:hover { + background: #4a9eff; + color: #fff; +} + /* Search */ .note-list__search { padding: 8px 12px; diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index 1df3fbcb..265ca7e4 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -81,13 +81,13 @@ const mockEntries: VaultEntry[] = [ describe('NoteList', () => { it('shows empty state when no entries', () => { - render() + render() expect(screen.getByText('No notes found')).toBeInTheDocument() expect(screen.getByText('0')).toBeInTheDocument() }) it('renders all entries with All Notes filter', () => { - render() + render() expect(screen.getByText('Build Laputa App')).toBeInTheDocument() expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() expect(screen.getByText('Matteo Cellini')).toBeInTheDocument() @@ -95,20 +95,20 @@ describe('NoteList', () => { }) it('filters by People', () => { - render() + render() expect(screen.getByText('Matteo Cellini')).toBeInTheDocument() expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() expect(screen.getByText('1')).toBeInTheDocument() }) it('filters by Events', () => { - render() + render() expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument() expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() }) it('filters by section group type', () => { - render() + render() expect(screen.getByText('Build Laputa App')).toBeInTheDocument() expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument() expect(screen.getByText('1')).toBeInTheDocument() @@ -116,7 +116,7 @@ describe('NoteList', () => { it('shows entity pinned at top with children', () => { render( - + ) // Pinned entity + child (Facebook Ads Strategy belongsTo this project) expect(screen.getByText('Build Laputa App')).toBeInTheDocument() @@ -127,7 +127,7 @@ describe('NoteList', () => { it('filters by topic (relatedTo references)', () => { render( - + ) // Build Laputa App has relatedTo: [[topic/software-development]] expect(screen.getByText('Build Laputa App')).toBeInTheDocument() @@ -135,12 +135,12 @@ describe('NoteList', () => { }) it('renders a search bar', () => { - render() + render() expect(screen.getByPlaceholderText('Search notes...')).toBeInTheDocument() }) it('filters by search query (case-insensitive substring)', () => { - render() + render() const input = screen.getByPlaceholderText('Search notes...') fireEvent.change(input, { target: { value: 'facebook' } }) expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() @@ -154,14 +154,14 @@ describe('NoteList', () => { { ...mockEntries[1], modifiedAt: 3000, title: 'Newest', path: '/p2' }, { ...mockEntries[2], modifiedAt: 2000, title: 'Middle', path: '/p3' }, ] - render() + render() const titles = screen.getAllByText(/Oldest|Newest|Middle/) const titleTexts = titles.map((el) => el.textContent) expect(titleTexts).toEqual(['Newest', 'Middle', 'Oldest']) }) it('renders type filter pills', () => { - const { container } = render() + const { container } = render() const pills = container.querySelectorAll('.note-list__pill') const labels = Array.from(pills).map((p) => p.textContent) expect(labels).toContain('All') @@ -172,7 +172,7 @@ describe('NoteList', () => { }) it('filters by type pill', () => { - render() + render() fireEvent.click(screen.getByText('Projects')) expect(screen.getByText('Build Laputa App')).toBeInTheDocument() expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument() @@ -180,7 +180,7 @@ describe('NoteList', () => { }) it('clicking All pill resets type filter', () => { - render() + render() fireEvent.click(screen.getByText('People')) expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() fireEvent.click(screen.getByText('All')) diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 43400f77..c5c0896a 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -7,6 +7,7 @@ interface NoteListProps { selection: SidebarSelection selectedNote: VaultEntry | null onSelectNote: (entry: VaultEntry) => void + onCreateNote: () => void } /** Check if a wikilink array (e.g. belongsTo) references a given entry by path stem */ @@ -68,7 +69,7 @@ const TYPE_PILLS = [ { label: 'Responsibilities', type: 'Responsibility' }, ] as const -export function NoteList({ entries, selection, selectedNote, onSelectNote }: NoteListProps) { +export function NoteList({ entries, selection, selectedNote, onSelectNote, onCreateNote }: NoteListProps) { const [search, setSearch] = useState('') const [typeFilter, setTypeFilter] = useState(null) @@ -98,7 +99,12 @@ export function NoteList({ entries, selection, selectedNote, onSelectNote }: Not

Notes

- {displayed.length} +
+ {displayed.length} + +
(cmd: string, args?: any): Promise { const handler = mockHandlers[cmd] if (handler) {