refactor: remove hardcoded RELATIONSHIP_KEYS — detect wikilink fields dynamically

Any frontmatter field whose value contains [[wikilinks]] now renders as a
relationship chip automatically. Fields with plain-text values always render
as editable properties, even if they were formerly hardcoded relationship keys.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-09 00:33:57 +01:00
parent 3e05a9425a
commit 9cc9ff80d8
7 changed files with 98 additions and 14 deletions

View File

@@ -16,7 +16,7 @@ This principle directly serves AI-readability: the more structure comes from sha
### No hardcoded exceptions
No field names, folder paths, or vault-specific values should be hardcoded in the application source code. What can be a convention should be a convention. What needs to be configurable should live in a file. Hardcoded lists (like `RELATIONSHIP_KEYS`) are a code smell — they create invisible walls that break when the user's vocabulary differs from the developer's assumptions.
No field names, folder paths, or vault-specific values should be hardcoded in the application source code. What can be a convention should be a convention. What needs to be configurable should live in a file. Relationship fields are detected dynamically by checking whether values contain `[[wikilinks]]` — no hardcoded field name lists.
### AI-first knowledge graph

View File

@@ -13,7 +13,7 @@ Before building new features, the architectural foundations must be solid. Key s
- Move vault cache outside the vault directory (→ `~/.laputa/cache/`) with atomic writes
- Flip `type:` to canonical field in Rust parser (`Is A:` becomes alias)
- Remove `allContent` from the architecture — derive backlinks from open tabs only
- Remove hardcoded `RELATIONSHIP_KEYS` — detect wikilink fields dynamically
- ~~Remove hardcoded `RELATIONSHIP_KEYS` — detect wikilink fields dynamically~~ ✅ Done
- Fix hardcoded vault path in `resolveNewNote` / `resolveNewType` / `resolveDailyNote`
- Define and enforce the three-source-of-truth contract (filesystem → cache → React state)

View File

@@ -166,7 +166,7 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByText('abc-123-def')).toBeInTheDocument()
})
it('skips aliases and relationship keys', () => {
it('skips aliases and fields with wikilink values', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -174,12 +174,37 @@ describe('DynamicPropertiesPanel', () => {
frontmatter={{ aliases: ['AL'], 'Belongs to': '[[Something]]', cadence: 'Monthly' }}
/>
)
// aliases and "Belongs to" should be skipped
// aliases skipped (in SKIP_KEYS); 'Belongs to' skipped (has wikilinks)
expect(screen.queryByText('aliases')).not.toBeInTheDocument()
expect(screen.queryByText('Belongs to')).not.toBeInTheDocument()
expect(screen.getByText('cadence')).toBeInTheDocument()
})
it('shows former relationship key with plain text value in Properties', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ 'Belongs to': 'some-team', cadence: 'Monthly' }}
/>
)
// 'Belongs to' has a plain text value, not a wikilink — should render as property
expect(screen.getByText('Belongs to')).toBeInTheDocument()
expect(screen.getByText('some-team')).toBeInTheDocument()
})
it('hides custom field with wikilink value from Properties', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ Mentor: '[[person/luca]]' }}
/>
)
// Mentor contains a wikilink → shown in Relationships, not Properties
expect(screen.queryByText('Mentor')).not.toBeInTheDocument()
})
it('skips is_a, Is A, and type keys (shown via TypeRow instead)', () => {
render(
<DynamicPropertiesPanel

View File

@@ -27,12 +27,6 @@ import { TagsDropdown } from './TagsDropdown'
import { getTagStyle } from '../utils/tagStyles'
import { ColorEditableValue } from './ColorInput'
// Keys that are relationships (contain wikilinks)
export const RELATIONSHIP_KEYS = new Set([
'Belongs to', 'Related to', 'Events', 'Has Data',
'Advances', 'Parent', 'Children', 'Has', 'Notes',
])
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
export function containsWikilinks(value: FrontmatterValue): boolean {
if (typeof value === 'string') return /^\[\[.*\]\]$/.test(value)

View File

@@ -2,7 +2,7 @@ import { useMemo, useCallback, useState, useRef } from 'react'
import type { VaultEntry } from '../../types'
import { X } from '@phosphor-icons/react'
import type { ParsedFrontmatter } from '../../utils/frontmatter'
import { RELATIONSHIP_KEYS, containsWikilinks } from '../DynamicPropertiesPanel'
import { containsWikilinks } from '../DynamicPropertiesPanel'
import type { FrontmatterValue } from '../Inspector'
import { NoteSearchList } from '../NoteSearchList'
import { useNoteSearch } from '../../hooks/useNoteSearch'
@@ -123,7 +123,7 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR
function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string; refs: string[] }[] {
return Object.entries(frontmatter)
.filter(([key, value]) => key !== 'Type' && (RELATIONSHIP_KEYS.has(key) || containsWikilinks(value)))
.filter(([key, value]) => key !== 'Type' && containsWikilinks(value))
.map(([key, value]) => {
const refs: string[] = []
if (typeof value === 'string' && isWikilink(value)) refs.push(value)

View File

@@ -8,7 +8,7 @@ import {
saveDisplayModeOverride,
removeDisplayModeOverride,
} from '../utils/propertyTypes'
import { RELATIONSHIP_KEYS, containsWikilinks } from '../components/DynamicPropertiesPanel'
import { containsWikilinks } from '../components/DynamicPropertiesPanel'
// Keys to skip showing in Properties (handled by dedicated UI or internal)
const SKIP_KEYS = new Set(['aliases', 'workspace', 'title', 'type', 'is_a', 'Is A'])
@@ -79,7 +79,7 @@ function collectAllVaultTags(entries: VaultEntry[] | undefined): Record<string,
}
function isVisibleProperty([key, value]: [string, FrontmatterValue]): boolean {
return !SKIP_KEYS.has(key) && !RELATIONSHIP_KEYS.has(key) && !containsWikilinks(value)
return !SKIP_KEYS.has(key) && !containsWikilinks(value)
}
function parseAddedValue(rawValue: string, mode: PropertyDisplayMode): FrontmatterValue {

View File

@@ -0,0 +1,65 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
async function openNoteViaQuickOpen(page: import('@playwright/test').Page, query: string) {
await page.locator('body').click()
await sendShortcut(page, 'p', ['Control'])
const searchInput = page.locator('input[placeholder="Search notes..."]')
await expect(searchInput).toBeVisible()
await searchInput.fill(query)
await page.waitForTimeout(500)
await page.keyboard.press('Enter')
await page.waitForTimeout(1000)
}
test.describe('Dynamic wikilink relationship detection', () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 })
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('wikilink fields render as relationships, plain-text fields as properties', async ({ page }) => {
// Open "Sponsorships" — has Has Measures/Has Procedures (custom wikilink arrays)
// and Status: Open (plain text)
await openNoteViaQuickOpen(page, 'Sponsorships')
// Wait for note to load — Status should render as editable property
const statusProp = page.locator('[data-testid="editable-property"]').filter({ hasText: 'Status' })
await expect(statusProp).toBeVisible({ timeout: 5000 })
await expect(statusProp.getByText('Open')).toBeVisible()
// 'Has Measures' has wikilinks → should NOT be in Properties
const measuresProp = page.locator('[data-testid="editable-property"]').filter({ hasText: 'Has Measures' })
await expect(measuresProp).not.toBeVisible()
// 'Has Measures' should appear as a relationship label
const measuresLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Has Measures' })
await expect(measuresLabel).toBeVisible()
// 'Has Procedures' has wikilinks → should be in Relationships
const proceduresLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Has Procedures' })
await expect(proceduresLabel).toBeVisible()
})
test('existing wikilink relationships still render correctly', async ({ page }) => {
// Open "Start Laputa App Project" — has Belongs to: [[24q4]], Owner: [[person-luca-rossi]]
await openNoteViaQuickOpen(page, 'Start Laputa App')
// Wait for note content to load
const statusProp = page.locator('[data-testid="editable-property"]').first()
await expect(statusProp).toBeVisible({ timeout: 5000 })
// 'Belongs to' has wikilink → should be in Relationships (not Properties)
const belongsToProp = page.locator('[data-testid="editable-property"]').filter({ hasText: 'Belongs to' })
await expect(belongsToProp).not.toBeVisible()
// 'Belongs to' should appear as a relationship label
const belongsToLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Belongs to' })
await expect(belongsToLabel).toBeVisible()
// 'Owner' has wikilink → should be in Relationships
const ownerProp = page.locator('[data-testid="editable-property"]').filter({ hasText: 'Owner' })
await expect(ownerProp).not.toBeVisible()
})
})