fix: restore built-in relationship chip icons
This commit is contained in:
@@ -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(
|
||||
<NoteItem
|
||||
entry={sourceEntry}
|
||||
isSelected={false}
|
||||
typeEntryMap={{ Topic: topicType, topic: topicType }}
|
||||
allEntries={[sourceEntry, linkedTopic, topicType]}
|
||||
displayPropsOverride={['Topics']}
|
||||
onClickNote={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
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',
|
||||
|
||||
@@ -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<SVGAttributes<SVGSVGElement>> | 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<string, VaultEntry>): VaultEntry | undefined {
|
||||
return targetEntry.isA ? (typeEntryMap[targetEntry.isA] ?? typeEntryMap[targetEntry.isA.toLowerCase()]) : undefined
|
||||
}
|
||||
|
||||
function findMatchingKey(values: Record<string, unknown>, propName: string): string | undefined {
|
||||
return Object.keys(values).find((key) => key.toLowerCase() === propName.toLowerCase())
|
||||
}
|
||||
|
||||
function resolveRelationshipChipStyle(targetEntry: VaultEntry, typeEntryMap: Record<string, VaultEntry>): 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<string, VaultEntry>,
|
||||
): 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<SVGAttributes<SVGSVGElement>> | null
|
||||
}) {
|
||||
const [imageFailed, setImageFailed] = useState(false)
|
||||
|
||||
if (tone === 'url') {
|
||||
return <Link aria-hidden="true" width={11} height={11} className="shrink-0" />
|
||||
}
|
||||
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 <resolvedNoteIcon.Icon aria-hidden="true" width={11} height={11} className="shrink-0" />
|
||||
}
|
||||
|
||||
if (resolvedNoteIcon.kind === 'image' && !imageFailed) {
|
||||
return (
|
||||
<img
|
||||
src={resolvedNoteIcon.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-[11px] w-[11px] shrink-0 rounded-sm object-cover"
|
||||
onError={() => setImageFailed(true)}
|
||||
/>
|
||||
)
|
||||
if (resolvedNoteIcon.kind !== 'image' || imageFailed) return null
|
||||
|
||||
return (
|
||||
<img
|
||||
src={resolvedNoteIcon.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-[11px] w-[11px] shrink-0 rounded-sm object-cover"
|
||||
onError={onImageError}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PropertyChipIcon({
|
||||
noteIcon,
|
||||
typeIcon,
|
||||
tone,
|
||||
}: {
|
||||
noteIcon?: string | null
|
||||
typeIcon?: ComponentType<SVGAttributes<SVGSVGElement>> | null
|
||||
tone: PropertyChipValue['tone']
|
||||
}) {
|
||||
const [imageFailed, setImageFailed] = useState(false)
|
||||
|
||||
if (tone === 'url') {
|
||||
return <Link aria-hidden="true" width={11} height={11} className="shrink-0" />
|
||||
}
|
||||
|
||||
if (!TypeIcon) return null
|
||||
return createElement(TypeIcon, { 'aria-hidden': true, width: 11, height: 11, className: 'shrink-0' })
|
||||
const noteIconElement = renderResolvedNoteIcon(noteIcon, imageFailed, () => setImageFailed(true))
|
||||
if (noteIconElement) return noteIconElement
|
||||
return <RelationshipTypeIcon typeIcon={typeIcon} />
|
||||
}
|
||||
|
||||
async function handleChipClick(
|
||||
|
||||
37
src/components/note-item/typeIcon.ts
Normal file
37
src/components/note-item/typeIcon.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ComponentType, SVGAttributes } from 'react'
|
||||
import {
|
||||
ArrowsClockwise,
|
||||
CalendarBlank,
|
||||
FileText,
|
||||
Flask,
|
||||
StackSimple,
|
||||
Tag,
|
||||
Target,
|
||||
Users,
|
||||
Wrench,
|
||||
} from '@phosphor-icons/react'
|
||||
import { resolveIcon } from '../../utils/iconRegistry'
|
||||
|
||||
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
|
||||
Project: Wrench,
|
||||
project: Wrench,
|
||||
Experiment: Flask,
|
||||
experiment: Flask,
|
||||
Responsibility: Target,
|
||||
responsibility: Target,
|
||||
Procedure: ArrowsClockwise,
|
||||
procedure: ArrowsClockwise,
|
||||
Person: Users,
|
||||
person: Users,
|
||||
Event: CalendarBlank,
|
||||
event: CalendarBlank,
|
||||
Topic: Tag,
|
||||
topic: Tag,
|
||||
Type: StackSimple,
|
||||
type: StackSimple,
|
||||
}
|
||||
|
||||
export function getTypeIcon(isA: string | null, customIcon?: string | null): ComponentType<SVGAttributes<SVGSVGElement>> {
|
||||
if (customIcon) return resolveIcon(customIcon)
|
||||
return (isA && TYPE_ICON_MAP[isA]) || FileText
|
||||
}
|
||||
Reference in New Issue
Block a user