Add create note dialog with type selector (M5 Task 1)
This commit is contained in:
131
src/components/CreateNoteDialog.css
Normal file
131
src/components/CreateNoteDialog.css
Normal 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;
|
||||
}
|
||||
100
src/components/CreateNoteDialog.tsx
Normal file
100
src/components/CreateNoteDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user