Add create note dialog with type selector (M5 Task 1)

This commit is contained in:
lucaronin
2026-02-14 21:14:16 +01:00
parent 2c981a0a30
commit 7b9d52d970
8 changed files with 405 additions and 17 deletions

57
e2e/create-note.spec.ts Normal file
View File

@@ -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()
})

View File

@@ -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<Record<string, string>>({})
const [gitHistory, setGitHistory] = useState<GitCommit[]>([])
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<string, string> = {
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() {
</div>
<ResizeHandle onResize={handleSidebarResize} />
<div className="app__note-list" style={{ width: noteListWidth }}>
<NoteList entries={entries} selection={selection} selectedNote={activeTab?.entry ?? null} onSelectNote={handleSelectNote} />
<NoteList entries={entries} selection={selection} selectedNote={activeTab?.entry ?? null} onSelectNote={handleSelectNote} onCreateNote={() => setShowCreateDialog(true)} />
</div>
<ResizeHandle onResize={handleNoteListResize} />
<div className="app__editor">
@@ -202,6 +258,11 @@ function App() {
onNavigate={handleNavigateWikilink}
/>
</div>
<CreateNoteDialog
open={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
onCreate={handleCreateNote}
/>
</div>
)
}

View File

@@ -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;
}

View File

@@ -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<NoteType>('Note')
const inputRef = useRef<HTMLInputElement>(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 (
<div className="create-dialog__overlay" onClick={onClose}>
<div className="create-dialog" onClick={(e) => e.stopPropagation()}>
<h3 className="create-dialog__title">Create New Note</h3>
<form onSubmit={handleSubmit}>
<div className="create-dialog__field">
<label className="create-dialog__label">Title</label>
<input
ref={inputRef}
className="create-dialog__input"
type="text"
placeholder="Enter note title..."
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="create-dialog__field">
<label className="create-dialog__label">Type</label>
<div className="create-dialog__types">
{NOTE_TYPES.map((t) => (
<button
key={t}
type="button"
className={`create-dialog__type-btn${type === t ? ' create-dialog__type-btn--active' : ''}`}
onClick={() => setType(t)}
>
{t}
</button>
))}
</div>
</div>
<div className="create-dialog__actions">
<button type="button" className="create-dialog__btn create-dialog__btn--cancel" onClick={onClose}>
Cancel
</button>
<button type="submit" className="create-dialog__btn create-dialog__btn--create" disabled={!title.trim()}>
Create
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -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;

View File

@@ -81,13 +81,13 @@ const mockEntries: VaultEntry[] = [
describe('NoteList', () => {
it('shows empty state when no entries', () => {
render(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} />)
render(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
expect(screen.getByText('No notes found')).toBeInTheDocument()
expect(screen.getByText('0')).toBeInTheDocument()
})
it('renders all entries with All Notes filter', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
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(<NoteList entries={mockEntries} selection={{ kind: 'filter', filter: 'people' }} selectedNote={null} onSelectNote={noopSelect} />)
render(<NoteList entries={mockEntries} selection={{ kind: 'filter', filter: 'people' }} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument()
})
it('filters by Events', () => {
render(<NoteList entries={mockEntries} selection={{ kind: 'filter', filter: 'events' }} selectedNote={null} onSelectNote={noopSelect} />)
render(<NoteList entries={mockEntries} selection={{ kind: 'filter', filter: 'events' }} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
})
it('filters by section group type', () => {
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} />)
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
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(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} />
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />
)
// 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(
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} />
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />
)
// 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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
expect(screen.getByPlaceholderText('Search notes...')).toBeInTheDocument()
})
it('filters by search query (case-insensitive substring)', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
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(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} />)
render(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} />)
const { container } = render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onCreateNote={vi.fn()} />)
fireEvent.click(screen.getByText('People'))
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('All'))

View File

@@ -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<string | null>(null)
@@ -98,7 +99,12 @@ export function NoteList({ entries, selection, selectedNote, onSelectNote }: Not
<div className="note-list">
<div className="note-list__header">
<h3>Notes</h3>
<span className="note-list__count">{displayed.length}</span>
<div className="note-list__header-right">
<span className="note-list__count">{displayed.length}</span>
<button className="note-list__add-btn" onClick={onCreateNote} title="Create new note">
+
</button>
</div>
</div>
<div className="note-list__search">
<input

View File

@@ -532,6 +532,11 @@ export function isTauri(): boolean {
return typeof window !== 'undefined' && '__TAURI__' in window
}
/** Register content for a new entry in mock mode (for get_note_content calls) */
export function addMockEntry(_entry: VaultEntry, content: string) {
MOCK_CONTENT[_entry.path] = content
}
export async function mockInvoke<T>(cmd: string, args?: any): Promise<T> {
const handler = mockHandlers[cmd]
if (handler) {