From 00b0c1d2b595988e45cf300f4b0a5abcefa3c54f Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 24 Feb 2026 13:47:30 +0100 Subject: [PATCH] feat: make relations editable with add/remove controls Add remove (X) button on hover for each related note in a relationship group, and inline add control with autocomplete to add new notes to existing relations. Changes update frontmatter via the existing update/delete property pipeline. Co-Authored-By: Claude Opus 4.6 --- src/components/Inspector.tsx | 7 +- src/components/InspectorPanels.test.tsx | 156 ++++++++++++++++++++++ src/components/InspectorPanels.tsx | 169 ++++++++++++++++++++---- 3 files changed, 307 insertions(+), 25 deletions(-) diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index a1b5d942..16aa95f0 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -146,7 +146,12 @@ export function Inspector({ onAddProperty={onAddProperty ? handleAddProperty : undefined} onNavigate={onNavigate} /> - + diff --git a/src/components/InspectorPanels.test.tsx b/src/components/InspectorPanels.test.tsx index 18d6cd4b..76f12049 100644 --- a/src/components/InspectorPanels.test.tsx +++ b/src/components/InspectorPanels.test.tsx @@ -188,6 +188,162 @@ describe('DynamicRelationshipsPanel', () => { ) expect(screen.getByText('My Cool Project')).toBeInTheDocument() }) + + describe('relation editing', () => { + const onUpdateProperty = vi.fn() + const onDeleteProperty = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows remove buttons on relation refs when editing is enabled', () => { + render( + + ) + expect(screen.getByTestId('remove-relation-ref')).toBeInTheDocument() + }) + + it('does not show remove buttons when editing is disabled', () => { + render( + + ) + expect(screen.queryByTestId('remove-relation-ref')).not.toBeInTheDocument() + }) + + it('calls onDeleteProperty when removing the last ref in a group', () => { + render( + + ) + fireEvent.click(screen.getByTestId('remove-relation-ref')) + expect(onDeleteProperty).toHaveBeenCalledWith('Belongs to') + }) + + it('calls onUpdateProperty with remaining refs when removing one of many', () => { + render( + + ) + const removeButtons = screen.getAllByTestId('remove-relation-ref') + fireEvent.click(removeButtons[0]) // Remove first ref + expect(onUpdateProperty).toHaveBeenCalledWith('Related to', '[[topic/ai]]') + }) + + it('calls onUpdateProperty with string when two refs become one', () => { + render( + + ) + const removeButtons = screen.getAllByTestId('remove-relation-ref') + fireEvent.click(removeButtons[0]) + // Should pass a single string, not an array of one + expect(onUpdateProperty).toHaveBeenCalledWith('Has', '[[topic/ai]]') + }) + + it('shows inline add button for each relationship group when editing is enabled', () => { + render( + + ) + expect(screen.getByTestId('add-relation-ref')).toBeInTheDocument() + }) + + it('opens inline add input when add button clicked', () => { + render( + + ) + fireEvent.click(screen.getByTestId('add-relation-ref')) + expect(screen.getByTestId('add-relation-ref-input')).toBeInTheDocument() + }) + + it('adds a note to an existing relationship via inline add', () => { + render( + + ) + fireEvent.click(screen.getByTestId('add-relation-ref')) + const input = screen.getByTestId('add-relation-ref-input') + fireEvent.change(input, { target: { value: 'AI' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[AI]]']) + }) + + it('does not add duplicate refs', () => { + render( + + ) + fireEvent.click(screen.getByTestId('add-relation-ref')) + const input = screen.getByTestId('add-relation-ref-input') + fireEvent.change(input, { target: { value: 'AI' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onUpdateProperty).not.toHaveBeenCalled() + }) + + it('closes inline add on Escape', () => { + render( + + ) + fireEvent.click(screen.getByTestId('add-relation-ref')) + const input = screen.getByTestId('add-relation-ref-input') + fireEvent.keyDown(input, { key: 'Escape' }) + expect(screen.queryByTestId('add-relation-ref-input')).not.toBeInTheDocument() + expect(screen.getByTestId('add-relation-ref')).toBeInTheDocument() + }) + }) }) describe('BacklinksPanel', () => { diff --git a/src/components/InspectorPanels.tsx b/src/components/InspectorPanels.tsx index e32b9a44..5ce14a64 100644 --- a/src/components/InspectorPanels.tsx +++ b/src/components/InspectorPanels.tsx @@ -4,7 +4,7 @@ import { wikilinkTarget, wikilinkDisplay } from '../utils/wikilink' import type { VaultEntry, GitCommit } from '../types' import { Wrench, Flask, Target, ArrowsClockwise, - Users, CalendarBlank, Tag, FileText, StackSimple, Trash, + Users, CalendarBlank, Tag, FileText, StackSimple, Trash, X, Plus, } from '@phosphor-icons/react' import type { ParsedFrontmatter } from '../utils/frontmatter' import { RELATIONSHIP_KEYS, containsWikilinks } from './DynamicPropertiesPanel' @@ -40,36 +40,49 @@ function StatusSuffix({ isArchived, isTrashed }: { isArchived: boolean; isTrashe return null } -function LinkButton({ label, typeColor, bgColor, isArchived, isTrashed, onClick, title, TypeIcon }: { +function LinkButton({ label, typeColor, bgColor, isArchived, isTrashed, onClick, onRemove, title, TypeIcon }: { label: string typeColor: string bgColor?: string isArchived: boolean isTrashed: boolean onClick: () => void + onRemove?: () => void title?: string TypeIcon: ComponentType> }) { const isDimmed = isArchived || isTrashed const color = isDimmed ? 'var(--muted-foreground)' : typeColor return ( - +
+ + {onRemove && ( + + )} +
) } @@ -94,8 +107,75 @@ function resolveRefProps(ref: string, entries: VaultEntry[]) { } } -function RelationshipGroup({ label, refs, entries, onNavigate }: { +function InlineAddNote({ entries, onAdd }: { + entries: VaultEntry[] + onAdd: (noteTitle: string) => void +}) { + const [active, setActive] = useState(false) + const [query, setQuery] = useState('') + const inputRef = useRef(null) + const noteTitles = useMemo(() => entries.map(e => e.title), [entries]) + + const handleSubmit = useCallback(() => { + const trimmed = query.trim() + if (!trimmed) return + onAdd(trimmed) + setQuery('') + setActive(false) + }, [query, onAdd]) + + if (!active) { + return ( + + ) + } + + return ( +
+ {noteTitles.map(t => + setQuery(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') handleSubmit() + else if (e.key === 'Escape') { setQuery(''); setActive(false) } + }} + data-testid="add-relation-ref-input" + /> + + +
+ ) +} + +function RelationshipGroup({ label, refs, entries, onNavigate, onRemoveRef, onAddRef }: { label: string; refs: string[]; entries: VaultEntry[]; onNavigate: (target: string) => void + onRemoveRef?: (ref: string) => void; onAddRef?: (noteTitle: string) => void }) { if (refs.length === 0) return null return ( @@ -104,9 +184,17 @@ function RelationshipGroup({ label, refs, entries, onNavigate }: {
{refs.map((ref, idx) => { const props = resolveRefProps(ref, entries) - return onNavigate(props.target)} /> + return ( + onNavigate(props.target)} + onRemove={onRemoveRef ? () => onRemoveRef(ref) : undefined} + /> + ) })}
+ {onAddRef && } ) } @@ -166,15 +254,48 @@ function AddRelationshipForm({ entries, onAddProperty }: { ) } -export function DynamicRelationshipsPanel({ frontmatter, entries, onNavigate, onAddProperty }: { - frontmatter: ParsedFrontmatter; entries: VaultEntry[]; onNavigate: (target: string) => void; onAddProperty?: (key: string, value: FrontmatterValue) => void +export function DynamicRelationshipsPanel({ frontmatter, entries, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty }: { + frontmatter: ParsedFrontmatter; entries: VaultEntry[]; onNavigate: (target: string) => void + onAddProperty?: (key: string, value: FrontmatterValue) => void + onUpdateProperty?: (key: string, value: FrontmatterValue) => void + onDeleteProperty?: (key: string) => void }) { const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter]) + + const handleRemoveRef = useCallback((key: string, refToRemove: string) => { + if (!onUpdateProperty || !onDeleteProperty) return + const group = relationshipEntries.find(g => g.key === key) + if (!group) return + const remaining = group.refs.filter(r => r !== refToRemove) + if (remaining.length === 0) onDeleteProperty(key) + else if (remaining.length === 1) onUpdateProperty(key, remaining[0]) + else onUpdateProperty(key, remaining) + }, [relationshipEntries, onUpdateProperty, onDeleteProperty]) + + const handleAddRef = useCallback((key: string, noteTitle: string) => { + if (!onUpdateProperty) return + const group = relationshipEntries.find(g => g.key === key) + const existing = group?.refs ?? [] + const newRef = `[[${noteTitle}]]` + if (existing.includes(newRef)) return + const updated = [...existing, newRef] + if (updated.length === 1) onUpdateProperty(key, updated[0]) + else onUpdateProperty(key, updated) + }, [relationshipEntries, onUpdateProperty]) + + const canEdit = !!onUpdateProperty && !!onDeleteProperty + return (
{relationshipEntries.length === 0 ?

No relationships

- : relationshipEntries.map(({ key, refs }) => ) + : relationshipEntries.map(({ key, refs }) => ( + handleRemoveRef(key, ref) : undefined} + onAddRef={canEdit ? (noteTitle) => handleAddRef(key, noteTitle) : undefined} + /> + )) } {onAddProperty ?