Files
tolaria/src/components/inspector/RelationshipsPanel.tsx
2026-05-26 10:52:53 +02:00

987 lines
32 KiB
TypeScript

import { useMemo, useCallback, useState, useRef, useEffect, type KeyboardEvent, type ReactNode } from 'react'
import type { VaultEntry } from '../../types'
import { Plus, X } from '@phosphor-icons/react'
import type { ParsedFrontmatter } from '../../utils/frontmatter'
import { containsWikilinks } from '../DynamicPropertiesPanel'
import type { FrontmatterValue } from '../Inspector'
import { NoteSearchList } from '../NoteSearchList'
import { useNoteSearch } from '../../hooks/useNoteSearch'
import {
resolveEntry,
canonicalWikilinkTargetForEntry,
canonicalWikilinkTargetForTitle,
formatWikilinkRef,
} from '../../utils/wikilink'
import { isWikilink, resolveRefProps } from './shared'
import { LinkButton } from './LinkButton'
import {
PROPERTY_PANEL_GRID_STYLE,
PROPERTY_PANEL_LABEL_CLASS_NAME,
PROPERTY_PANEL_PLACEHOLDER_LABEL_CLASS_NAME,
} from '../propertyPanelLayout'
import { humanizePropertyKey } from '../../utils/propertyLabels'
import { translate, type AppLocale } from '../../lib/i18n'
import { canonicalFrontmatterKey } from '../../utils/systemMetadata'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
const RELATIONSHIP_SECTION_ROW_CLASS_NAME = 'flex min-w-0 flex-col gap-1 px-1.5'
const RELATIONSHIPS_PANEL_GRID_CLASS_NAME = 'grid min-w-0 gap-x-2 gap-y-3'
const RELATIONSHIP_SECTION_LABEL_TEXT_CLASS_NAME = 'min-w-0 flex-1 truncate'
const RELATIONSHIP_SECTION_VALUE_CLASS_NAME = 'min-w-0'
const RELATIONSHIP_ACTION_ROW_CLASS_NAME = 'min-w-0 px-1.5'
const SUGGESTED_RELATIONSHIPS = ['belongs_to', 'related_to', 'has'] as const
const RELATIONSHIP_SCHEMA_KEYS = new Set(['belongs_to', 'related_to', 'has'])
type RelationshipEntryGroup = {
key: string
refs: string[]
}
type RelationshipPanelEditHandlers = {
onAddProperty?: (key: string, value: FrontmatterValue) => void
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
}
interface RelationshipLookupContext {
entries: VaultEntry[]
sourceEntry?: VaultEntry
vaultPath: string
}
interface CreateOptionArgs {
entries: VaultEntry[]
trimmedQuery: string
resultCount: number
hasCreator: boolean
}
interface SearchDropdownArgs {
focused: boolean
trimmed: string
resultCount: number
showCreate: boolean
}
interface TitleSelectionState {
showCreate: boolean
selectedIndex: number
createIndex: number
trimmed: string
}
function RelationshipSectionRow({ label, children, dataTestId, placeholder = false }: {
label: string
children: ReactNode
dataTestId?: string
locale: AppLocale
placeholder?: boolean
}) {
const labelClassName = placeholder ? PROPERTY_PANEL_PLACEHOLDER_LABEL_CLASS_NAME : PROPERTY_PANEL_LABEL_CLASS_NAME
const labelTextClassName = placeholder
? `${RELATIONSHIP_SECTION_LABEL_TEXT_CLASS_NAME} text-muted-foreground/40`
: RELATIONSHIP_SECTION_LABEL_TEXT_CLASS_NAME
return (
<div className={RELATIONSHIP_SECTION_ROW_CLASS_NAME} style={{ gridColumn: '1 / -1' }} data-testid={dataTestId}>
<span className={labelClassName} data-testid="relationship-section-label">
<span className={labelTextClassName}>{humanizePropertyKey(label)}</span>
</span>
<div className={RELATIONSHIP_SECTION_VALUE_CLASS_NAME}>{children}</div>
</div>
)
}
function RelationshipActionRow({ children }: { children: ReactNode }) {
return (
<div className={RELATIONSHIP_ACTION_ROW_CLASS_NAME} style={{ gridColumn: '1 / -1' }}>
<div className={RELATIONSHIP_SECTION_VALUE_CLASS_NAME}>{children}</div>
</div>
)
}
/** Check whether any entry resolves for the given title (exact match via wikilink resolution). */
function hasExactTitleMatch({ entries, title }: { entries: VaultEntry[]; title: string }): boolean {
return resolveEntry(entries, title) !== undefined
}
function inferVaultPath(entries: VaultEntry[]): string {
if (entries.length === 0) return ''
const segments = entries.map((entry) => entry.path.split('/').slice(0, -1))
const prefix: string[] = []
const maxDepth = Math.min(...segments.map((parts) => parts.length))
for (let i = 0; i < maxDepth; i += 1) {
const segment = segments.at(0)?.at(i)
if (segment !== undefined && segments.every((parts) => parts.at(i) === segment)) prefix.push(segment)
else break
}
return prefix.join('/')
}
function canonicalRefForEntry({ entry, sourceEntry, vaultPath }: { entry: VaultEntry } & RelationshipLookupContext): string {
return formatWikilinkRef(canonicalWikilinkTargetForEntry(entry, vaultPath, sourceEntry))
}
function canonicalRefForTitle({ title, entries, sourceEntry, vaultPath }: { title: string } & RelationshipLookupContext): string {
return formatWikilinkRef(canonicalWikilinkTargetForTitle(title, entries, vaultPath, sourceEntry))
}
function shouldShowSearchDropdown({ focused, trimmed, resultCount, showCreate }: SearchDropdownArgs): boolean {
return focused && trimmed.length > 0 && (resultCount > 0 || showCreate)
}
function confirmRelationshipSelection({
showCreate,
selectedIndex,
createIndex,
trimmed,
selectedEntry,
onCreate,
onSelectEntry,
onFallback,
}: {
showCreate: boolean
selectedIndex: number
createIndex: number
trimmed: string
selectedEntry?: VaultEntry
onCreate?: (title: string) => void
onSelectEntry?: (entry: VaultEntry) => void
onFallback?: () => void
}): void {
if (shouldCreateRelationship(titleSelectionState({ showCreate, selectedIndex, createIndex, trimmed }))) {
onCreate?.(trimmed)
return
}
if (hasSelectedRelationshipEntry(selectedEntry)) {
onSelectEntry?.(selectedEntry)
return
}
onFallback?.()
}
function titleSelectionState(state: TitleSelectionState): TitleSelectionState {
return state
}
function shouldCreateRelationship({ showCreate, selectedIndex, createIndex, trimmed }: TitleSelectionState): boolean {
return showCreate && selectedIndex === createIndex && trimmed.length > 0
}
function hasSelectedRelationshipEntry(selectedEntry?: VaultEntry): selectedEntry is VaultEntry {
return selectedEntry !== undefined
}
/** Shared keyboard navigation for search dropdowns with an optional "create" item. */
function useSearchKeyboard(
search: ReturnType<typeof useNoteSearch>,
totalItems: number,
onConfirm: () => void,
onEscape: () => void,
) {
return useCallback((e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
search.setSelectedIndex((i: number) => Math.min(i + 1, totalItems - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
search.setSelectedIndex((i: number) => Math.max(i - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
onConfirm()
} else if (e.key === 'Escape') {
onEscape()
}
}, [search, totalItems, onConfirm, onEscape])
}
/** Wraps the create-and-open-note pattern: calls the async creator, then defers a side-effect to the next tick. */
function useCreateAndOpen(
onCreateAndOpenNote: ((title: string) => Promise<boolean>) | undefined,
afterCreate: (title: string) => void,
onDone: () => void,
) {
return useCallback(async (title: string) => {
if (!onCreateAndOpenNote || !title) return
const ok = await onCreateAndOpenNote(title)
if (!ok) return
// Defer frontmatter update to next tick to avoid radix-ui
// infinite setState loop from overlapping render batches
setTimeout(() => afterCreate(title), 0)
onDone()
}, [onCreateAndOpenNote, afterCreate, onDone])
}
/** Derives create-option state from search results and entries. */
function useCreateOption(
{
entries,
trimmedQuery,
resultCount,
hasCreator,
}: CreateOptionArgs,
) {
const showCreate = hasCreator && trimmedQuery.length > 0 && !hasExactTitleMatch({ entries, title: trimmedQuery })
return { showCreate, createIndex: resultCount, totalItems: resultCount + (showCreate ? 1 : 0) }
}
function CreateAndOpenOption({ title, selected, locale, onClick, onHover }: {
title: string
selected: boolean
locale: AppLocale
onClick: () => void
onHover: () => void
}) {
return (
<button
type="button"
className={`flex w-full cursor-pointer items-center gap-2 border-0 bg-transparent px-3 py-1.5 text-left text-sm transition-colors ${selected ? 'bg-accent' : 'hover:bg-secondary'}`}
data-testid="create-and-open-option"
onMouseDown={e => e.preventDefault()}
onClick={onClick}
onMouseEnter={onHover}
>
<Plus size={14} className="shrink-0 text-muted-foreground" />
<span className="truncate text-foreground">
{translate(locale, 'inspector.relationship.createAndOpen')} <strong>{title}</strong>
</span>
</button>
)
}
function SearchDropdownWithCreate({ search, onSelect, query, entries, locale, onCreateAndOpen }: {
search: ReturnType<typeof useNoteSearch>
onSelect: (entry: VaultEntry) => void
query: string
entries: VaultEntry[]
onCreateAndOpen?: (title: string) => void
locale: AppLocale
}) {
const trimmed = query.trim()
const { showCreate, createIndex } = useCreateOption({
entries,
trimmedQuery: trimmed,
resultCount: search.results.length,
hasCreator: !!onCreateAndOpen,
})
const hasResults = search.results.length > 0
if (!hasResults && !showCreate) return null
return (
<div className="absolute left-0 right-0 top-full z-50 mt-0.5 rounded border border-border bg-popover shadow-md">
{hasResults && (
<NoteSearchList
items={search.results}
selectedIndex={search.selectedIndex}
getItemKey={(item) => item.entry.path}
onItemClick={(item) => onSelect(item.entry)}
onItemHover={(i) => search.setSelectedIndex(i)}
className="max-h-[160px] overflow-y-auto"
/>
)}
{showCreate && onCreateAndOpen && (
<CreateAndOpenOption
title={trimmed}
selected={search.selectedIndex === createIndex}
locale={locale}
onClick={() => onCreateAndOpen(trimmed)}
onHover={() => search.setSelectedIndex(createIndex)}
/>
)}
</div>
)
}
function useInlineAddNoteState(
entries: VaultEntry[],
vaultPath: string,
sourceEntry: VaultEntry | undefined,
onAdd: (ref: string) => void,
onCreateAndOpenNote?: (title: string) => Promise<boolean>,
) {
const [active, setActive] = useState(false)
const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const search = useNoteSearch(entries, query, 8)
const lookupContext = useMemo(() => ({ entries, sourceEntry, vaultPath }), [entries, sourceEntry, vaultPath])
const trimmed = query.trim()
const { showCreate, createIndex, totalItems } = useCreateOption({
entries,
trimmedQuery: trimmed,
resultCount: search.results.length,
hasCreator: !!onCreateAndOpenNote,
})
const dismiss = useCallback(() => {
setQuery('')
setActive(false)
}, [])
const selectAndClose = useCallback((ref: string) => {
onAdd(ref)
dismiss()
}, [onAdd, dismiss])
const selectEntryAndClose = useCallback((entry: VaultEntry) => {
selectAndClose(canonicalRefForEntry({ entry, entries, sourceEntry, vaultPath }))
}, [entries, selectAndClose, sourceEntry, vaultPath])
const handleCreateAndOpen = useCreateAndOpen(
onCreateAndOpenNote,
(title) => onAdd(canonicalRefForTitle({ title, ...lookupContext })),
dismiss,
)
const handleFallback = useCallback(() => {
if (!trimmed) return
selectAndClose(canonicalRefForTitle({ title: trimmed, ...lookupContext }))
}, [trimmed, selectAndClose, lookupContext])
const handleConfirm = useCallback(() => {
confirmRelationshipSelection({
showCreate,
selectedIndex: search.selectedIndex,
createIndex,
trimmed,
selectedEntry: search.selectedEntry ?? undefined,
onCreate: handleCreateAndOpen,
onSelectEntry: selectEntryAndClose,
onFallback: handleFallback,
})
}, [showCreate, search.selectedIndex, search.selectedEntry, createIndex, trimmed, handleCreateAndOpen, selectEntryAndClose, handleFallback])
const handleKeyDown = useSearchKeyboard(search, totalItems, handleConfirm, dismiss)
const showDropdown = shouldShowSearchDropdown({
focused: active,
trimmed,
resultCount: search.results.length,
showCreate,
})
return {
active,
setActive,
query,
setQuery,
inputRef,
search,
dismiss,
handleKeyDown,
showDropdown,
selectEntryAndClose,
showCreate,
handleCreateAndOpen,
}
}
function InlineAddNote({ entries, sourceEntry, vaultPath, locale, onAdd, onCreateAndOpenNote }: {
entries: VaultEntry[]
sourceEntry?: VaultEntry
vaultPath: string
onAdd: (ref: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
locale: AppLocale
}) {
const {
active,
setActive,
query,
setQuery,
inputRef,
search,
dismiss,
handleKeyDown,
showDropdown,
selectEntryAndClose,
handleCreateAndOpen,
} = useInlineAddNoteState(entries, vaultPath, sourceEntry, onAdd, onCreateAndOpenNote)
useEffect(() => {
if (active) inputRef.current?.focus()
}, [active, inputRef])
if (!active) {
return (
<button type="button"
className="mt-1 w-full border border-dashed border-border bg-transparent text-left text-muted-foreground cursor-pointer hover:border-foreground hover:text-foreground"
style={{ borderRadius: 6, padding: '6px 10px', fontSize: 12 }}
onClick={() => setActive(true)}
data-testid="add-relation-ref"
>
{translate(locale, 'inspector.relationship.add')}
</button>
)
}
return (
<div className="relative mt-1">
<div className="group/add relative flex items-center">
<input
ref={inputRef}
className="w-full border border-border bg-transparent text-foreground"
style={{ borderRadius: 6, outline: 'none', minWidth: 0, padding: '6px 10px', fontSize: 12 }}
placeholder={translate(locale, 'inspector.relationship.noteTitle')}
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
data-testid="add-relation-ref-input"
/>
<button type="button"
className="absolute right-1 top-1/2 -translate-y-1/2 border-none bg-transparent p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/add:opacity-100"
onClick={dismiss}
>
<X size={12} />
</button>
</div>
{showDropdown && (
<SearchDropdownWithCreate
search={search}
onSelect={selectEntryAndClose}
query={query}
entries={entries}
onCreateAndOpen={onCreateAndOpenNote ? (title) => { handleCreateAndOpen(title) } : undefined}
locale={locale}
/>
)}
</div>
)
}
function RelationshipGroup({ label, refs, entries, sourceEntry, typeEntryMap, vaultPath, locale, onNavigate, onRemoveRef, onAddRef, onCreateAndOpenNote }: {
label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath: string
sourceEntry?: VaultEntry
onNavigate: (target: string) => void
onRemoveRef?: (ref: string) => void
onAddRef?: (ref: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
locale: AppLocale
}) {
if (refs.length === 0) return null
return (
<RelationshipSectionRow label={label} locale={locale}>
<div className="flex flex-col gap-1">
{refs.map((ref) => {
const props = resolveRefProps(ref, entries, typeEntryMap)
return (
<LinkButton
key={`${ref}-${props.target}`}
{...props}
onClick={() => onNavigate(props.target)}
onRemove={onRemoveRef ? () => onRemoveRef(ref) : undefined}
/>
)
})}
</div>
{onAddRef && (
<InlineAddNote
entries={entries}
sourceEntry={sourceEntry}
vaultPath={vaultPath}
onAdd={onAddRef}
onCreateAndOpenNote={onCreateAndOpenNote}
locale={locale}
/>
)}
</RelationshipSectionRow>
)
}
function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string; refs: string[] }[] {
return Object.entries(frontmatter)
.filter(([key, value]) => key !== 'Type' && containsWikilinks(value))
.map(([key, value]) => {
const refs: string[] = []
if (typeof value === 'string' && isWikilink(value)) refs.push(value)
else if (Array.isArray(value)) value.forEach(v => { if (typeof v === 'string' && isWikilink(v)) refs.push(v) })
return { key, refs }
})
.filter(({ refs }) => refs.length > 0)
}
function findTypeEntry(entries: VaultEntry[], entry?: VaultEntry): VaultEntry | undefined {
if (!entry?.isA || entry.isA === 'Type') return undefined
return entries.find((candidate) => candidate.isA === 'Type' && candidate.title === entry.isA)
}
function isRelationshipSchemaKey(key: string): boolean {
return RELATIONSHIP_SCHEMA_KEYS.has(canonicalFrontmatterKey(key))
}
function buildExistingRelationshipKeys(frontmatter: ParsedFrontmatter, relationshipEntries: RelationshipEntryGroup[]): Set<string> {
const existingKeys = new Set(Object.keys(frontmatter).map(canonicalFrontmatterKey))
for (const group of relationshipEntries) existingKeys.add(canonicalFrontmatterKey(group.key))
return existingKeys
}
function buildTypeDerivedRelationshipEntries({
entry,
entries,
frontmatter,
relationshipEntries,
}: {
entry?: VaultEntry
entries: VaultEntry[]
frontmatter: ParsedFrontmatter
relationshipEntries: RelationshipEntryGroup[]
}): RelationshipEntryGroup[] {
const typeEntry = findTypeEntry(entries, entry)
if (!typeEntry) return []
const existingKeys = buildExistingRelationshipKeys(frontmatter, relationshipEntries)
const result: RelationshipEntryGroup[] = []
const seen = new Set<string>()
const addPlaceholder = (key: string) => {
const canonicalKey = canonicalFrontmatterKey(key)
if (canonicalKey === 'type' || existingKeys.has(canonicalKey) || seen.has(canonicalKey)) return
seen.add(canonicalKey)
result.push({ key, refs: [] })
}
for (const [key, refs] of Object.entries(typeEntry.relationships ?? {})) {
if (refs.length > 0) addPlaceholder(key)
}
for (const key of Object.keys(typeEntry.properties ?? {})) {
if (isRelationshipSchemaKey(key)) addPlaceholder(key)
}
return result
}
function NoteTargetInput({ entries, value, locale, onChange, onSelectEntry, onSubmit, onCancel, onCreateAndOpenNote, onSubmitWithCreate }: {
entries: VaultEntry[]
value: string
onChange: (v: string) => void
onSelectEntry?: (entry: VaultEntry) => void
onSubmit?: () => void
onCancel?: () => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onSubmitWithCreate?: (title: string) => void
locale: AppLocale
}) {
const [focused, setFocused] = useState(false)
const search = useNoteSearch(entries, value, 8)
const trimmed = value.trim()
const { showCreate, createIndex, totalItems } = useCreateOption({
entries,
trimmedQuery: trimmed,
resultCount: search.results.length,
hasCreator: !!onCreateAndOpenNote,
})
const selectEntry = useCallback((entry: VaultEntry) => {
onSelectEntry?.(entry)
if (!onSelectEntry) onChange(entry.title)
setFocused(false)
}, [onChange, onSelectEntry])
const handleConfirm = useCallback(() => {
confirmRelationshipSelection({
showCreate,
selectedIndex: search.selectedIndex,
createIndex,
trimmed,
selectedEntry: search.selectedEntry ?? undefined,
onCreate: onSubmitWithCreate,
onSelectEntry: selectEntry,
onFallback: onSubmit,
})
}, [showCreate, search.selectedIndex, search.selectedEntry, createIndex, trimmed, onSubmitWithCreate, selectEntry, onSubmit])
const handleEscape = useCallback(() => {
onCancel?.()
}, [onCancel])
const handleKeyDown = useSearchKeyboard(search, totalItems, handleConfirm, handleEscape)
const showDropdown = shouldShowSearchDropdown({
focused,
trimmed,
resultCount: search.results.length,
showCreate,
})
return (
<div className="relative">
<input
className="w-full border border-border bg-transparent px-2 py-1 text-xs text-foreground"
style={{ borderRadius: 4, outline: 'none' }}
placeholder={translate(locale, 'inspector.relationship.noteTitle')}
value={value}
onChange={e => onChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setTimeout(() => setFocused(false), 150)}
onKeyDown={handleKeyDown}
/>
{showDropdown && (
<SearchDropdownWithCreate
search={search}
onSelect={selectEntry}
query={value}
entries={entries}
onCreateAndOpen={onCreateAndOpenNote ? (title) => onSubmitWithCreate?.(title) : undefined}
locale={locale}
/>
)}
</div>
)
}
function useRelationshipMutations(
relationshipEntries: RelationshipEntryGroup[],
handlers: RelationshipPanelEditHandlers,
) {
const { onUpdateProperty, onDeleteProperty } = handlers
const handleRemoveRef = useCallback((key: string, refToRemove: string) => {
if (!onUpdateProperty || !onDeleteProperty) return
const group = relationshipEntries.find(g => g.key === key)
if (!group) return
const result = updateRefsForRemoval(group.refs, refToRemove)
if (result === null) onDeleteProperty(key)
else onUpdateProperty(key, result)
}, [relationshipEntries, onUpdateProperty, onDeleteProperty])
const handleAddRef = useCallback((key: string, ref: string) => {
if (!onUpdateProperty) return
const existing = relationshipEntries.find(g => g.key === key)?.refs ?? []
const result = updateRefsForAddition(existing, ref)
if (result !== false) onUpdateProperty(key, result)
}, [relationshipEntries, onUpdateProperty])
return {
handleRemoveRef,
handleAddRef,
canEdit: Boolean(onUpdateProperty && onDeleteProperty),
}
}
function useMissingSuggestedRelationships(
relationshipEntries: RelationshipEntryGroup[],
onAddProperty?: RelationshipPanelEditHandlers['onAddProperty'],
) {
const existingRelKeys = useMemo(
() => new Set(relationshipEntries.map(g => g.key.toLowerCase())),
[relationshipEntries],
)
return useMemo(
() => (onAddProperty ? SUGGESTED_RELATIONSHIPS.filter(r => !existingRelKeys.has(r.toLowerCase())) : []),
[onAddProperty, existingRelKeys],
)
}
function useRelationshipPanelState({
entry,
frontmatter,
entries,
vaultPath,
onAddProperty,
onUpdateProperty,
onDeleteProperty,
}: {
entry?: VaultEntry
frontmatter: ParsedFrontmatter
entries: VaultEntry[]
vaultPath?: string
} & RelationshipPanelEditHandlers) {
const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter])
const typeDerivedRelationshipEntries = useMemo(
() => buildTypeDerivedRelationshipEntries({ entry, entries, frontmatter, relationshipEntries }),
[entry, entries, frontmatter, relationshipEntries],
)
const resolvedVaultPath = useMemo(() => vaultPath ?? inferVaultPath(entries), [vaultPath, entries])
const { handleRemoveRef, handleAddRef, canEdit } = useRelationshipMutations(relationshipEntries, {
onAddProperty,
onUpdateProperty,
onDeleteProperty,
})
const missingSuggestedRels = useMissingSuggestedRelationships(
[...relationshipEntries, ...typeDerivedRelationshipEntries],
onAddProperty,
)
return {
relationshipEntries,
typeDerivedRelationshipEntries,
resolvedVaultPath,
handleRemoveRef,
handleAddRef,
canEdit,
missingSuggestedRels,
}
}
interface AddRelationshipFormProps {
entries: VaultEntry[]
sourceEntry?: VaultEntry
vaultPath: string
onAddProperty: (key: string, value: FrontmatterValue) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
locale: AppLocale
}
function AddRelationshipButton({ locale, onClick }: { locale: AppLocale; onClick: () => void }) {
return (
<Button type="button" variant="outline" className="mt-2 h-auto w-full justify-center px-3 py-1.5 text-xs text-muted-foreground" onClick={onClick}>
{translate(locale, 'inspector.relationship.addRelationship')}
</Button>
)
}
function RelationshipKeyInput({
inputRef,
locale,
value,
onChange,
onSubmit,
onCancel,
}: {
inputRef: React.RefObject<HTMLInputElement | null>
locale: AppLocale
value: string
onChange: (value: string) => void
onSubmit: () => void
onCancel: () => void
}) {
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') onSubmit()
if (event.key === 'Escape') onCancel()
}
return (
<Input
ref={inputRef}
autoFocus
placeholder={translate(locale, 'inspector.relationship.name')}
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={handleKeyDown}
className="h-7 text-xs"
/>
)
}
function AddRelationshipActions({
canSubmit,
locale,
onCancel,
onSubmit,
}: {
canSubmit: boolean
locale: AppLocale
onCancel: () => void
onSubmit: () => void
}) {
return (
<div className="flex gap-1.5">
<Button type="button" variant="outline" size="sm" className="h-7 flex-1 text-xs" onClick={onSubmit} disabled={!canSubmit} data-testid="submit-add-relationship">
{translate(locale, 'inspector.relationship.add')}
</Button>
<Button type="button" variant="outline" size="sm" className="h-7 px-2 text-xs text-muted-foreground" onClick={onCancel}>
{translate(locale, 'common.cancel')}
</Button>
</div>
)
}
function AddRelationshipForm({ entries, sourceEntry, vaultPath, locale, onAddProperty, onCreateAndOpenNote }: AddRelationshipFormProps) {
const [relKey, setRelKey] = useState('')
const [relTarget, setRelTarget] = useState('')
const [selectedTargetEntry, setSelectedTargetEntry] = useState<VaultEntry | null>(null)
const [showForm, setShowForm] = useState(false)
const keyInputRef = useRef<HTMLInputElement>(null)
const resetForm = useCallback(() => {
setShowForm(false)
setRelKey('')
setRelTarget('')
setSelectedTargetEntry(null)
}, [])
const openForm = useCallback(() => {
setShowForm(true)
setTimeout(() => keyInputRef.current?.focus(), 0)
}, [])
const handleTargetChange = useCallback((value: string) => {
setRelTarget(value)
setSelectedTargetEntry(null)
}, [])
const handleTargetEntrySelected = useCallback((entry: VaultEntry) => {
setSelectedTargetEntry(entry)
setRelTarget(entry.title)
}, [])
const submitForm = useCallback((targetOverride?: string) => {
const key = relKey.trim()
const rawTarget = (targetOverride ?? relTarget).trim()
if (!key || !rawTarget) return
const ref = !targetOverride && selectedTargetEntry
? canonicalRefForEntry({ entry: selectedTargetEntry, entries, sourceEntry, vaultPath })
: canonicalRefForTitle({ title: rawTarget, entries, sourceEntry, vaultPath })
onAddProperty(key, ref)
resetForm()
}, [relKey, relTarget, selectedTargetEntry, entries, sourceEntry, vaultPath, onAddProperty, resetForm])
const addPropertyForKey = useCallback((title: string) => {
const key = relKey.trim()
if (key) onAddProperty(key, canonicalRefForTitle({ title, entries, sourceEntry, vaultPath }))
}, [relKey, entries, sourceEntry, vaultPath, onAddProperty])
const handleCreateAndSubmit = useCreateAndOpen(onCreateAndOpenNote, addPropertyForKey, resetForm)
if (!showForm) {
return <AddRelationshipButton locale={locale} onClick={openForm} />
}
return (
<div className="mt-2 flex flex-col gap-1.5">
<RelationshipKeyInput
inputRef={keyInputRef}
locale={locale}
value={relKey}
onChange={setRelKey}
onSubmit={() => submitForm()}
onCancel={resetForm}
/>
<NoteTargetInput
entries={entries}
value={relTarget}
onChange={handleTargetChange}
onSelectEntry={handleTargetEntrySelected}
onSubmit={submitForm}
onCancel={resetForm}
onCreateAndOpenNote={onCreateAndOpenNote}
onSubmitWithCreate={handleCreateAndSubmit}
locale={locale}
/>
<AddRelationshipActions
canSubmit={!!relKey.trim() && !!relTarget.trim()}
locale={locale}
onSubmit={() => submitForm()}
onCancel={resetForm}
/>
</div>
)
}
function updateRefsForRemoval(refs: string[], refToRemove: string): FrontmatterValue | null {
const remaining = refs.filter(r => r !== refToRemove)
if (remaining.length === 0) return null
return remaining.length === 1 ? remaining[0] : remaining
}
function updateRefsForAddition(refs: string[], refToAdd: string): FrontmatterValue | false {
if (refs.includes(refToAdd)) return false
const updated = [...refs, refToAdd]
return updated.length === 1 ? updated[0] : updated
}
function DisabledLinkButton({ locale }: { locale: AppLocale }) {
return (
<button type="button" className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.5, cursor: 'not-allowed' }} disabled>{translate(locale, 'inspector.relationship.addRelationship')}</button>
)
}
function SuggestedRelationshipSlot({ label, entries, sourceEntry, vaultPath, locale, onAdd, onCreateAndOpenNote, dataTestId = 'suggested-relationship' }: {
label: string
entries: VaultEntry[]
sourceEntry?: VaultEntry
vaultPath: string
onAdd: (ref: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
locale: AppLocale
dataTestId?: string
}) {
return (
<RelationshipSectionRow label={label} dataTestId={dataTestId} locale={locale} placeholder>
<InlineAddNote
entries={entries}
sourceEntry={sourceEntry}
vaultPath={vaultPath}
onAdd={onAdd}
onCreateAndOpenNote={onCreateAndOpenNote}
locale={locale}
/>
</RelationshipSectionRow>
)
}
export function DynamicRelationshipsPanel({ entry, frontmatter, entries, typeEntryMap, vaultPath, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote, locale = 'en' }: {
entry?: VaultEntry; frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath?: string
onNavigate: (target: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
locale?: AppLocale
}) {
const {
relationshipEntries,
typeDerivedRelationshipEntries,
resolvedVaultPath,
handleRemoveRef,
handleAddRef,
canEdit,
missingSuggestedRels,
} = useRelationshipPanelState({
entry,
frontmatter,
entries,
vaultPath,
onAddProperty,
onUpdateProperty,
onDeleteProperty,
})
return (
<div className={RELATIONSHIPS_PANEL_GRID_CLASS_NAME} style={PROPERTY_PANEL_GRID_STYLE} data-testid="relationships-panel-grid">
{relationshipEntries.map(({ key, refs }) => (
<RelationshipGroup
key={key} label={key} refs={refs} entries={entries} typeEntryMap={typeEntryMap} vaultPath={resolvedVaultPath} onNavigate={onNavigate}
sourceEntry={entry}
onRemoveRef={canEdit ? (ref) => handleRemoveRef(key, ref) : undefined}
onAddRef={canEdit ? (ref) => handleAddRef(key, ref) : undefined}
onCreateAndOpenNote={canEdit ? onCreateAndOpenNote : undefined}
locale={locale}
/>
))}
{onAddProperty && typeDerivedRelationshipEntries.map(({ key }) => (
<SuggestedRelationshipSlot
key={`type-derived:${key}`}
label={key}
entries={entries}
sourceEntry={entry}
vaultPath={resolvedVaultPath}
onAdd={(ref) => onAddProperty(key, ref)}
onCreateAndOpenNote={onCreateAndOpenNote}
locale={locale}
dataTestId="type-derived-relationship"
/>
))}
{missingSuggestedRels.map(label => (
<SuggestedRelationshipSlot
key={label}
label={label}
entries={entries}
sourceEntry={entry}
vaultPath={resolvedVaultPath}
onAdd={(ref) => onAddProperty!(label, ref)}
onCreateAndOpenNote={onCreateAndOpenNote}
locale={locale}
/>
))}
<RelationshipActionRow>
{onAddProperty
? <AddRelationshipForm entries={entries} sourceEntry={entry} vaultPath={resolvedVaultPath} onAddProperty={onAddProperty} onCreateAndOpenNote={onCreateAndOpenNote} locale={locale} />
: <DisabledLinkButton locale={locale} />
}
</RelationshipActionRow>
</div>
)
}