Compare commits

...

3 Commits

Author SHA1 Message Date
Test
8ec33b99b5 docs: add mandatory UI components rules to CLAUDE.md — always use shadcn/ui, never raw HTML 2026-04-04 09:56:30 +02:00
Test
638678184e fix: add type="button" to EmojiPicker buttons to prevent form submission
Emoji buttons inside the EmojiPicker defaulted to type="submit", causing
premature form submission when selecting an emoji inside the Create/Edit
View dialog. The form submitted with the stale (empty) icon state before
the emoji selection could update it, resulting in views always saving
with icon: null regardless of the user's emoji choice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 04:59:18 +02:00
Test
62af7a3d6e fix: parse date strings in view filter before/after operators
Date properties are stored as YAML strings (e.g. "2024-03-15"), not
Unix timestamps. Parse string values via Date.parse so before/after
operators work correctly. Also handles numeric Unix timestamps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 04:07:11 +02:00
6 changed files with 159 additions and 5 deletions

View File

@@ -107,6 +107,27 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never co
2. Design in light mode. Create `design/<slug>.pen` for the task
3. On completion: merge frames into `ui-design.pen`, delete `design/<slug>.pen`
### UI components — mandatory rules
**Always use shadcn/ui components.** Never use raw HTML form elements (`<input>`, `<select>`, `<button>`, native `<input type="date">`, etc.) for user-facing UI. Every interactive element must use the shadcn/ui equivalent:
| Need | Use |
|---|---|
| Text input | `Input` from shadcn/ui |
| Dropdown/select | `Select` from shadcn/ui |
| Date picker | `Calendar` + `Popover` from shadcn/ui (NOT native `<input type="date">`) |
| Button | `Button` from shadcn/ui |
| Autocomplete/combobox | Reuse existing combobox components from the app (check `src/components/`) |
| Wikilink picker | Reuse the wikilink autocomplete component already used in the editor and Properties panel |
| Emoji picker | Reuse the emoji picker component already used for note/type icons |
| Color picker | Reuse the color swatch picker used for type customization |
| Toggle/switch | `Switch` or `ToggleGroup` from shadcn/ui |
| Dialog/modal | `Dialog` from shadcn/ui |
**When in doubt:** search `src/components/` for an existing component that does what you need before building a new one. The app already has many reusable pieces — use them.
**Visual language:** all new UI must feel native to Laputa. Take inspiration from `ui-design.pen` and existing components. If something looks like a browser default, it's wrong.
---
## 4. Reference

View File

@@ -714,6 +714,30 @@ filters:
assert_eq!(views.len(), 0);
}
#[test]
fn test_save_and_read_view_with_emoji_icon() {
let dir = tempfile::TempDir::new().unwrap();
let def = ViewDefinition {
name: "Monday".to_string(),
icon: Some("🗂️".to_string()),
color: None,
sort: None,
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
field: "type".to_string(),
op: FilterOp::Equals,
value: Some(serde_yaml::Value::String("Project".to_string())),
})]),
};
save_view(dir.path(), "monday.yml", &def).unwrap();
let views = scan_views(dir.path());
assert_eq!(views.len(), 1);
assert_eq!(views[0].definition.name, "Monday");
assert_eq!(views[0].definition.icon.as_deref(), Some("🗂️"));
}
#[test]
fn test_wikilink_stem_matching() {
let yaml = r#"

View File

@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { CreateViewDialog } from './CreateViewDialog'
import type { ViewDefinition } from '../types'
@@ -42,4 +42,51 @@ describe('CreateViewDialog', () => {
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
expect(input).toHaveValue('Active Projects')
})
it('preserves emoji icon when editing a view', () => {
const onCreate = vi.fn()
const editingView: ViewDefinition = {
name: 'Monday',
icon: '🗂️',
color: null,
sort: null,
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
}
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} editingView={editingView} />)
// Submit the form without changing anything
fireEvent.submit(screen.getByText('Save').closest('form')!)
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ icon: '🗂️' })
)
})
it('passes selected emoji icon when creating a view', () => {
const onCreate = vi.fn()
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} />)
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
fireEvent.change(input, { target: { value: 'Test View' } })
// Open emoji picker and select an emoji
fireEvent.click(screen.getByTitle('Pick icon'))
expect(screen.getByTestId('emoji-picker')).toBeInTheDocument()
const emojiButtons = screen.getAllByTestId('emoji-option')
fireEvent.click(emojiButtons[0])
// Submit the form
fireEvent.click(screen.getByText('Create'))
expect(onCreate).toHaveBeenCalledTimes(1)
const definition = onCreate.mock.calls[0][0] as ViewDefinition
expect(definition.icon).not.toBeNull()
expect(typeof definition.icon).toBe('string')
expect(definition.icon!.length).toBeGreaterThan(0)
})
it('passes null icon when no emoji is selected', () => {
const onCreate = vi.fn()
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} />)
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
fireEvent.change(input, { target: { value: 'No Icon View' } })
fireEvent.submit(screen.getByText('Create').closest('form')!)
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ icon: null })
)
})
})

View File

@@ -71,6 +71,7 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
<div className="grid grid-cols-8 gap-0.5">
{searchResults.map(entry => (
<button
type="button"
key={entry.emoji}
className="flex h-8 w-8 items-center justify-center rounded text-xl transition-colors hover:bg-accent"
onClick={() => handleSelect(entry.emoji)}
@@ -98,6 +99,7 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
<div className="grid grid-cols-8 gap-0.5">
{emojis.map(entry => (
<button
type="button"
key={entry.emoji}
className="flex h-8 w-8 items-center justify-center rounded text-xl transition-colors hover:bg-accent"
onClick={() => handleSelect(entry.emoji)}

View File

@@ -190,4 +190,58 @@ describe('evaluateView', () => {
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Exact'])
})
it('before operator works with ISO date strings in properties', () => {
const view: ViewDefinition = {
name: 'Before', icon: null, color: null, sort: null,
filters: { all: [{ field: 'Date', op: 'before', value: '2024-06-01' }] },
}
const entries = [
makeEntry({ title: 'Early', properties: { Date: '2024-03-15' } }),
makeEntry({ title: 'Late', properties: { Date: '2024-09-01' } }),
makeEntry({ title: 'NoDate', properties: {} }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Early'])
})
it('after operator works with ISO date strings in properties', () => {
const view: ViewDefinition = {
name: 'After', icon: null, color: null, sort: null,
filters: { all: [{ field: 'Date', op: 'after', value: '2024-06-01' }] },
}
const entries = [
makeEntry({ title: 'Early', properties: { Date: '2024-03-15' } }),
makeEntry({ title: 'Late', properties: { Date: '2024-09-01' } }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Late'])
})
it('before/after works with ISO datetime strings', () => {
const view: ViewDefinition = {
name: 'Before datetime', icon: null, color: null, sort: null,
filters: { all: [{ field: 'Date', op: 'before', value: '2024-03-15T12:00:00' }] },
}
const entries = [
makeEntry({ title: 'Morning', properties: { Date: '2024-03-15T08:00:00' } }),
makeEntry({ title: 'Evening', properties: { Date: '2024-03-15T18:00:00' } }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Morning'])
})
it('before/after works with numeric Unix timestamps', () => {
const view: ViewDefinition = {
name: 'After ts', icon: null, color: null, sort: null,
filters: { all: [{ field: 'Date', op: 'after', value: '2024-01-01' }] },
}
// Unix timestamp for 2024-06-15 in seconds
const ts = Math.floor(new Date('2024-06-15').getTime() / 1000)
const entries = [
makeEntry({ title: 'Match', properties: { Date: ts } }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Match'])
})
})

View File

@@ -104,11 +104,17 @@ function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean {
// Date comparisons
if (op === 'before' || op === 'after') {
const ts = typeof resolved.scalar === 'number' ? resolved.scalar : null
if (!ts) return false
const target = Date.parse(condVal) / 1000
let tsMs: number | null = null
if (typeof resolved.scalar === 'number') {
tsMs = resolved.scalar * 1000 // Unix timestamp (seconds) → milliseconds
} else if (typeof resolved.scalar === 'string') {
const parsed = Date.parse(resolved.scalar)
tsMs = isNaN(parsed) ? null : parsed
}
if (tsMs == null) return false
const target = Date.parse(condVal)
if (isNaN(target)) return false
return op === 'before' ? ts < target : ts > target
return op === 'before' ? tsMs < target : tsMs > target
}
return false