diff --git a/src/components/NoteItem.test.tsx b/src/components/NoteItem.test.tsx index fdb738bb..4a756261 100644 --- a/src/components/NoteItem.test.tsx +++ b/src/components/NoteItem.test.tsx @@ -134,6 +134,46 @@ describe('NoteItem', () => { expect(onClickNote).toHaveBeenCalledWith(linkedProject, expect.objectContaining({ metaKey: true })) }) + it('falls back to the built-in type icon for relationship chips when the Type has no custom icon', () => { + const linkedTopic = makeEntry({ + path: '/vault/topic/ai.md', + filename: 'ai.md', + title: 'AI', + isA: 'topic', + }) + const topicType = makeEntry({ + path: '/vault/type/topic.md', + filename: 'topic.md', + title: 'Topic', + isA: 'Type', + color: 'green', + icon: null, + }) + const sourceEntry = makeEntry({ + path: '/vault/note/source.md', + filename: 'source.md', + title: 'Source', + isA: 'Note', + relationships: { Topics: ['[[topic/ai]]'] }, + }) + + render( + , + ) + + const chip = screen.getByTestId('property-chip-topics-0') + expect(chip).toHaveTextContent('Ai') + expect(chip).toHaveStyle({ color: 'var(--accent-green)', backgroundColor: 'var(--accent-green-light)' }) + expect(chip.querySelector('svg')).not.toBeNull() + }) + it('opens URL chips on Cmd+click only and keeps regular clicks inert', () => { const entry = makeEntry({ path: '/vault/note/source.md', diff --git a/src/components/note-item/PropertyChips.tsx b/src/components/note-item/PropertyChips.tsx index 11e8cfee..53bc3a3d 100644 --- a/src/components/note-item/PropertyChips.tsx +++ b/src/components/note-item/PropertyChips.tsx @@ -1,17 +1,17 @@ -import { createElement, useMemo, useState, type CSSProperties, type MouseEvent } from 'react' +import { createElement, useMemo, useState, type ComponentType, type CSSProperties, type MouseEvent, type ReactNode, type SVGAttributes } from 'react' import { Link } from '@phosphor-icons/react' import { cn } from '@/lib/utils' import type { VaultEntry } from '../../types' -import { findIcon } from '../../utils/iconRegistry' import { resolveNoteIcon } from '../../utils/noteIcon' import { getTypeColor, getTypeLightColor } from '../../utils/typeColors' import { isUrlValue, normalizeUrl, openExternalUrl } from '../../utils/url' import { resolveEntry, wikilinkDisplay, wikilinkTarget } from '../../utils/wikilink' +import { getTypeIcon } from './typeIcon' interface PropertyChipValue { label: string noteIcon: string | null - typeIcon: string | null + typeIcon: ComponentType> | null style?: CSSProperties action?: { kind: 'note'; entry: VaultEntry } | { kind: 'url'; url: string } tone: 'neutral' | 'relationship' | 'url' @@ -46,8 +46,16 @@ function formatChipLabel(value: unknown): string | null { return raw.length > 40 ? `${raw.slice(0, 37)}…` : raw } +function resolveTargetTypeEntry(targetEntry: VaultEntry, typeEntryMap: Record): VaultEntry | undefined { + return targetEntry.isA ? (typeEntryMap[targetEntry.isA] ?? typeEntryMap[targetEntry.isA.toLowerCase()]) : undefined +} + +function findMatchingKey(values: Record, propName: string): string | undefined { + return Object.keys(values).find((key) => key.toLowerCase() === propName.toLowerCase()) +} + function resolveRelationshipChipStyle(targetEntry: VaultEntry, typeEntryMap: Record): CSSProperties | undefined { - const typeEntry = targetEntry.isA ? (typeEntryMap[targetEntry.isA] ?? typeEntryMap[targetEntry.isA.toLowerCase()]) : undefined + const typeEntry = resolveTargetTypeEntry(targetEntry, typeEntryMap) const color = getTypeColor(targetEntry.isA, typeEntry?.color) const backgroundColor = getTypeLightColor(targetEntry.isA, typeEntry?.color) if (color === 'var(--muted-foreground)' && backgroundColor === 'var(--muted)') return undefined @@ -72,11 +80,11 @@ function resolveRelationshipChip( } } - const typeEntry = targetEntry.isA ? (typeEntryMap[targetEntry.isA] ?? typeEntryMap[targetEntry.isA.toLowerCase()]) : undefined + const typeEntry = resolveTargetTypeEntry(targetEntry, typeEntryMap) return { label, noteIcon: targetEntry.icon ?? null, - typeIcon: targetEntry.isA ? typeEntry?.icon ?? null : null, + typeIcon: targetEntry.isA ? getTypeIcon(targetEntry.isA, typeEntry?.icon) : null, style: resolveRelationshipChipStyle(targetEntry, typeEntryMap), action: { kind: 'note', entry: targetEntry }, tone: 'relationship', @@ -107,6 +115,30 @@ function resolveScalarChip(value: unknown): PropertyChipValue | null { } } +function resolveRelationshipChipValues( + entry: VaultEntry, + propName: string, + allEntries: VaultEntry[], + typeEntryMap: Record, +): PropertyChipValue[] | null { + const relationshipKey = findMatchingKey(entry.relationships, propName) + if (!relationshipKey) return null + return entry.relationships[relationshipKey] + .map((ref) => resolveRelationshipChip(ref, allEntries, typeEntryMap)) + .filter((chip): chip is PropertyChipValue => chip !== null) +} + +function resolveScalarChipValues(entry: VaultEntry, propName: string): PropertyChipValue[] { + const propertyKey = findMatchingKey(entry.properties, propName) + if (!propertyKey) return [] + + const rawValue = entry.properties[propertyKey] + const values = Array.isArray(rawValue) ? rawValue : [rawValue] + return values + .map((value) => resolveScalarChip(value)) + .filter((chip): chip is PropertyChipValue => chip !== null) +} + function resolvePropertyChipValues( entry: VaultEntry, propName: string, @@ -118,40 +150,24 @@ function resolvePropertyChipValues( return statusChip ? [statusChip] : [] } - const relationshipKey = Object.keys(entry.relationships).find((key) => key.toLowerCase() === propName.toLowerCase()) - if (relationshipKey) { - return entry.relationships[relationshipKey] - .map((ref) => resolveRelationshipChip(ref, allEntries, typeEntryMap)) - .filter((chip): chip is PropertyChipValue => chip !== null) - } - - const propertyKey = Object.keys(entry.properties).find((key) => key.toLowerCase() === propName.toLowerCase()) - if (!propertyKey) return [] - - const rawValue = entry.properties[propertyKey] - const values = Array.isArray(rawValue) ? rawValue : [rawValue] - return values - .map((value) => resolveScalarChip(value)) - .filter((chip): chip is PropertyChipValue => chip !== null) + return resolveRelationshipChipValues(entry, propName, allEntries, typeEntryMap) ?? resolveScalarChipValues(entry, propName) } -function PropertyChipIcon({ - noteIcon, +function RelationshipTypeIcon({ typeIcon, - tone, }: { - noteIcon?: string | null - typeIcon?: string | null - tone: PropertyChipValue['tone'] + typeIcon?: ComponentType> | null }) { - const [imageFailed, setImageFailed] = useState(false) - - if (tone === 'url') { - return - } + if (!typeIcon) return null + return createElement(typeIcon, { 'aria-hidden': true, width: 11, height: 11, className: 'shrink-0' }) +} +function renderResolvedNoteIcon( + noteIcon: string | null | undefined, + imageFailed, + onImageError, +): ReactNode { const resolvedNoteIcon = resolveNoteIcon(noteIcon) - const TypeIcon = findIcon(typeIcon) if (resolvedNoteIcon.kind === 'emoji') { return ( @@ -165,20 +181,37 @@ function PropertyChipIcon({ return