Compare commits

...

2 Commits

Author SHA1 Message Date
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
5 changed files with 138 additions and 5 deletions

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