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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
65
tests/smoke/remove-relationship-keys.spec.ts
Normal file
65
tests/smoke/remove-relationship-keys.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user