diff --git a/src/components/NoteItem.test.tsx b/src/components/NoteItem.test.tsx index 093e8c0e..fdb738bb 100644 --- a/src/components/NoteItem.test.tsx +++ b/src/components/NoteItem.test.tsx @@ -1,9 +1,22 @@ import { render, screen, fireEvent } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { NoteItem } from './NoteItem' import { makeEntry } from '../test-utils/noteListTestUtils' +vi.mock('../utils/url', async () => { + const actual = await vi.importActual('../utils/url') as typeof import('../utils/url') + return { ...actual, openExternalUrl: vi.fn().mockResolvedValue(undefined) } +}) + +const { openExternalUrl } = await import('../utils/url') as typeof import('../utils/url') & { + openExternalUrl: ReturnType +} + describe('NoteItem', () => { + beforeEach(() => { + openExternalUrl.mockClear() + }) + it('renders binary files as non-clickable muted rows', () => { const binaryEntry = makeEntry({ path: '/vault/photo.png', @@ -73,4 +86,113 @@ describe('NoteItem', () => { expect(screen.queryByText('note.md')).not.toBeInTheDocument() expect(screen.queryByTestId('change-status-icon')).not.toBeInTheDocument() }) + + it('colors relationship chips by target type and opens the related note on Cmd+click only', () => { + const linkedProject = makeEntry({ + path: '/vault/project/build-app.md', + filename: 'build-app.md', + title: 'Build App', + isA: 'Project', + }) + const projectType = makeEntry({ + path: '/vault/type/project.md', + filename: 'project.md', + title: 'Project', + isA: 'Type', + color: 'red', + icon: 'wrench', + }) + const sourceEntry = makeEntry({ + path: '/vault/note/source.md', + filename: 'source.md', + title: 'Source', + isA: 'Note', + relationships: { 'Belongs to': ['[[project/build-app]]'] }, + }) + const onClickNote = vi.fn() + + render( + , + ) + + const chip = screen.getByTestId('property-chip-belongs-to-0') + expect(chip).toHaveTextContent('Build App') + expect(chip.className).toContain('cursor-pointer') + expect(chip).toHaveStyle({ color: 'var(--accent-red)', backgroundColor: 'var(--accent-red-light)' }) + + fireEvent.click(chip) + expect(onClickNote).not.toHaveBeenCalled() + + fireEvent.click(chip, { metaKey: true }) + expect(onClickNote).toHaveBeenCalledWith(linkedProject, expect.objectContaining({ metaKey: true })) + }) + + it('opens URL chips on Cmd+click only and keeps regular clicks inert', () => { + const entry = makeEntry({ + path: '/vault/note/source.md', + filename: 'source.md', + title: 'Source', + properties: { URL: 'https://example.com/docs' }, + }) + const onClickNote = vi.fn() + + render( + , + ) + + const chip = screen.getByTestId('property-chip-url-0') + expect(chip).toHaveTextContent('example.com') + expect(chip.className).toContain('cursor-pointer') + expect(chip).toHaveStyle({ color: 'var(--accent-blue)', backgroundColor: 'var(--accent-blue-light)' }) + + fireEvent.click(chip) + expect(openExternalUrl).not.toHaveBeenCalled() + expect(onClickNote).not.toHaveBeenCalled() + + fireEvent.click(chip, { metaKey: true }) + expect(openExternalUrl).toHaveBeenCalledWith('https://example.com/docs') + expect(onClickNote).not.toHaveBeenCalled() + }) + + it('renders broken relationship chips as neutral and non-interactive', () => { + const entry = makeEntry({ + path: '/vault/note/source.md', + filename: 'source.md', + title: 'Source', + relationships: { Related: ['[[missing/note]]'] }, + }) + const onClickNote = vi.fn() + + render( + , + ) + + const chip = screen.getByTestId('property-chip-related-0') + expect(chip).toHaveTextContent('Note') + expect(chip.className).not.toContain('cursor-pointer') + + fireEvent.click(chip, { metaKey: true }) + expect(onClickNote).not.toHaveBeenCalled() + expect(openExternalUrl).not.toHaveBeenCalled() + }) }) diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index 14058f2c..7430fff6 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -1,4 +1,4 @@ -import { createElement, useMemo, useState, type ComponentType, type SVGAttributes } from 'react' +import { useMemo, type ComponentType, type SVGAttributes } from 'react' import type { VaultEntry, NoteStatus } from '../types' import { cn } from '@/lib/utils' import { @@ -7,11 +7,10 @@ import { File, FileDashed, } from '@phosphor-icons/react' import { getTypeColor, getTypeLightColor } from '../utils/typeColors' -import { findIcon, resolveIcon } from '../utils/iconRegistry' +import { resolveIcon } from '../utils/iconRegistry' import { relativeDate, getDisplayDate } from '../utils/noteListHelpers' -import { resolveNoteIcon } from '../utils/noteIcon' -import { resolveEntry, wikilinkDisplay, wikilinkTarget } from '../utils/wikilink' import { NoteTitleIcon } from './NoteTitleIcon' +import { PropertyChips } from './note-item/PropertyChips' const TYPE_ICON_MAP: Record>> = { Project: Wrench, @@ -60,134 +59,6 @@ function StateBadge({ archived }: { archived: boolean }) { return null } -function formatChipValue(value: unknown): string | null { - if (value === null || value === undefined || value === '') return null - const s = String(value) - // URL: show only hostname - try { - if (s.startsWith('http://') || s.startsWith('https://')) return new URL(s).hostname - } catch { /* not a URL */ } - return s.length > 40 ? s.slice(0, 37) + '…' : s -} - -interface PropertyChipValue { - label: string - noteIcon: string | null - typeIcon: string | null -} - -function resolveChipValues( - entry: VaultEntry, - propName: string, - allEntries: VaultEntry[], - typeEntryMap: Record, -): PropertyChipValue[] { - if (propName.toLowerCase() === 'status') { - const formatted = formatChipValue(entry.status) - return formatted ? [{ label: formatted, noteIcon: null, typeIcon: null }] : [] - } - // Check relationships first (wikilink values) - const relKey = Object.keys(entry.relationships).find((k) => k.toLowerCase() === propName.toLowerCase()) - if (relKey) { - return entry.relationships[relKey] - .map((ref) => { - const targetEntry = resolveEntry(allEntries, wikilinkTarget(ref)) - const label = wikilinkDisplay(ref) - return label ? { - label, - noteIcon: targetEntry?.icon ?? null, - typeIcon: targetEntry?.isA ? typeEntryMap[targetEntry.isA]?.icon ?? null : null, - } : null - }) - .filter((value): value is PropertyChipValue => value !== null) - } - // Check scalar properties - const propKey = Object.keys(entry.properties).find((k) => k.toLowerCase() === propName.toLowerCase()) - if (!propKey) return [] - const val = entry.properties[propKey] - if (Array.isArray(val)) { - return val - .map((v) => formatChipValue(v)) - .filter((v): v is string => v !== null) - .map((label) => ({ label, noteIcon: null, typeIcon: null })) - } - const formatted = formatChipValue(val) - return formatted ? [{ label: formatted, noteIcon: null, typeIcon: null }] : [] -} - -function PropertyChipIcon({ noteIcon, typeIcon }: { noteIcon?: string | null; typeIcon?: string | null }) { - const [imageFailed, setImageFailed] = useState(false) - const resolvedNoteIcon = resolveNoteIcon(noteIcon) - const TypeIcon = findIcon(typeIcon) - - if (resolvedNoteIcon.kind === 'emoji') { - return ( - - ) - } - - if (resolvedNoteIcon.kind === 'phosphor') { - return