fix: note list shows incomplete relationships when opening from sidebar
Two root causes: 1. noteListHooks used a stale selection.entry to build relationship groups — now looks up the fresh entry from the entries array so relationship updates propagate immediately. 2. frontmatterToEntryPatch didn't update entry.relationships — added RelationshipPatch support so frontmatter changes also update the relationships map in state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,7 +56,10 @@ export function useNoteListData({ entries, selection, query, listSort, listDirec
|
||||
|
||||
const searchedGroups = useMemo(() => {
|
||||
if (!isEntityView) return []
|
||||
const groups = buildRelationshipGroups(selection.entry, entries)
|
||||
// Look up the fresh entry from the entries array to pick up relationship
|
||||
// updates that happened after the selection was captured.
|
||||
const freshEntry = entries.find((e) => e.path === selection.entry.path) ?? selection.entry
|
||||
const groups = buildRelationshipGroups(freshEntry, entries)
|
||||
return filterGroupsByQuery(groups, query)
|
||||
}, [isEntityView, selection, entries, query])
|
||||
|
||||
|
||||
@@ -13,12 +13,38 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
||||
template: { template: null }, sort: { sort: null }, visible: { visible: null },
|
||||
}
|
||||
|
||||
/** Check if a string contains a wikilink pattern `[[...]]`. */
|
||||
function isWikilink(s: string): boolean {
|
||||
return s.startsWith('[[') && s.includes(']]')
|
||||
}
|
||||
|
||||
/** Extract wikilink strings from a FrontmatterValue. Returns empty array if none. */
|
||||
function extractWikilinks(value: FrontmatterValue): string[] {
|
||||
if (typeof value === 'string') return isWikilink(value) ? [value] : []
|
||||
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string' && isWikilink(v))
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationship patch: a partial update to merge into `entry.relationships`.
|
||||
* Keys map to their new ref arrays. A `null` value means "remove this key".
|
||||
*/
|
||||
export type RelationshipPatch = Record<string, string[] | null>
|
||||
|
||||
export interface EntryPatchResult {
|
||||
patch: Partial<VaultEntry>
|
||||
relationshipPatch: RelationshipPatch | null
|
||||
}
|
||||
|
||||
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
|
||||
export function frontmatterToEntryPatch(
|
||||
op: 'update' | 'delete', key: string, value?: FrontmatterValue,
|
||||
): Partial<VaultEntry> {
|
||||
): EntryPatchResult {
|
||||
const k = key.toLowerCase().replace(/\s+/g, '_')
|
||||
if (op === 'delete') return ENTRY_DELETE_MAP[k] ?? {}
|
||||
if (op === 'delete') {
|
||||
const relPatch: RelationshipPatch = { [key]: null }
|
||||
return { patch: ENTRY_DELETE_MAP[k] ?? {}, relationshipPatch: relPatch }
|
||||
}
|
||||
const str = value != null ? String(value) : null
|
||||
const arr = Array.isArray(value) ? value.map(String) : []
|
||||
const updates: Record<string, Partial<VaultEntry>> = {
|
||||
@@ -32,7 +58,11 @@ export function frontmatterToEntryPatch(
|
||||
view: { view: str },
|
||||
visible: { visible: value === false ? false : null },
|
||||
}
|
||||
return updates[k] ?? {}
|
||||
// Also update the relationships map for wikilink-containing values
|
||||
const wikilinks = value != null ? extractWikilinks(value) : []
|
||||
const relationshipPatch: RelationshipPatch | null =
|
||||
wikilinks.length > 0 ? { [key]: wikilinks } : null
|
||||
return { patch: updates[k] ?? {}, relationshipPatch }
|
||||
}
|
||||
|
||||
async function invokeFrontmatter(command: string, args: Record<string, unknown>): Promise<string> {
|
||||
@@ -65,18 +95,42 @@ export interface FrontmatterOpOptions {
|
||||
silent?: boolean
|
||||
}
|
||||
|
||||
/** Apply a relationship patch by merging into the existing relationships map. */
|
||||
export function applyRelationshipPatch(
|
||||
existing: Record<string, string[]>, relPatch: RelationshipPatch,
|
||||
): Record<string, string[]> {
|
||||
const merged = { ...existing }
|
||||
for (const [k, v] of Object.entries(relPatch)) {
|
||||
if (v === null) delete merged[k]
|
||||
else merged[k] = v
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/** Run a frontmatter update/delete and apply the result to state.
|
||||
* Returns the new file content on success, or undefined on failure. */
|
||||
export async function runFrontmatterAndApply(
|
||||
op: 'update' | 'delete', path: string, key: string, value: FrontmatterValue | undefined,
|
||||
callbacks: { updateTab: (p: string, c: string) => void; updateEntry: (p: string, patch: Partial<VaultEntry>) => void; toast: (m: string | null) => void },
|
||||
callbacks: {
|
||||
updateTab: (p: string, c: string) => void
|
||||
updateEntry: (p: string, patch: Partial<VaultEntry>) => void
|
||||
toast: (m: string | null) => void
|
||||
getEntry?: (p: string) => VaultEntry | undefined
|
||||
},
|
||||
options?: FrontmatterOpOptions,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const newContent = await executeFrontmatterOp(op, path, key, value)
|
||||
callbacks.updateTab(path, newContent)
|
||||
const patch = frontmatterToEntryPatch(op, key, value)
|
||||
if (Object.keys(patch).length > 0) callbacks.updateEntry(path, patch)
|
||||
const { patch, relationshipPatch } = frontmatterToEntryPatch(op, key, value)
|
||||
const fullPatch = { ...patch }
|
||||
if (relationshipPatch && callbacks.getEntry) {
|
||||
const current = callbacks.getEntry(path)
|
||||
if (current) {
|
||||
fullPatch.relationships = applyRelationshipPatch(current.relationships, relationshipPatch)
|
||||
}
|
||||
}
|
||||
if (Object.keys(fullPatch).length > 0) callbacks.updateEntry(path, fullPatch)
|
||||
if (!options?.silent) callbacks.toast(op === 'update' ? 'Property updated' : 'Property deleted')
|
||||
return newContent
|
||||
} catch (err) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
resolveTemplate,
|
||||
} from './useNoteCreation'
|
||||
import { needsRenameOnSave } from './useNoteRename'
|
||||
import { frontmatterToEntryPatch } from './frontmatterOps'
|
||||
import { frontmatterToEntryPatch, applyRelationshipPatch } from './frontmatterOps'
|
||||
import { useNoteActions } from './useNoteActions'
|
||||
import type { NoteActionsConfig } from './useNoteActions'
|
||||
|
||||
@@ -345,25 +345,45 @@ describe('frontmatterToEntryPatch', () => {
|
||||
] as [string, unknown, Partial<VaultEntry>][])(
|
||||
'maps %s update to correct entry field',
|
||||
(key, value, expected) => {
|
||||
expect(frontmatterToEntryPatch('update', key, value as never)).toEqual(expected)
|
||||
expect(frontmatterToEntryPatch('update', key, value as never).patch).toEqual(expected)
|
||||
},
|
||||
)
|
||||
|
||||
it('maps aliases update with array value', () => {
|
||||
expect(frontmatterToEntryPatch('update', 'aliases', ['A', 'B'])).toEqual({ aliases: ['A', 'B'] })
|
||||
expect(frontmatterToEntryPatch('update', 'aliases', ['A', 'B']).patch).toEqual({ aliases: ['A', 'B'] })
|
||||
})
|
||||
|
||||
it('maps belongs_to update with array value', () => {
|
||||
expect(frontmatterToEntryPatch('update', 'belongs_to', ['[[parent]]'])).toEqual({ belongsTo: ['[[parent]]'] })
|
||||
const result = frontmatterToEntryPatch('update', 'belongs_to', ['[[parent]]'])
|
||||
expect(result.patch).toEqual({ belongsTo: ['[[parent]]'] })
|
||||
// Also produces a relationship patch for the wikilink
|
||||
expect(result.relationshipPatch).toEqual({ 'belongs_to': ['[[parent]]'] })
|
||||
})
|
||||
|
||||
it('handles case-insensitive keys with spaces (e.g. "Is A", "Belongs to")', () => {
|
||||
expect(frontmatterToEntryPatch('update', 'Is A', 'Experiment')).toEqual({ isA: 'Experiment' })
|
||||
expect(frontmatterToEntryPatch('update', 'Belongs to', ['[[x]]'])).toEqual({ belongsTo: ['[[x]]'] })
|
||||
expect(frontmatterToEntryPatch('update', 'Is A', 'Experiment').patch).toEqual({ isA: 'Experiment' })
|
||||
const result = frontmatterToEntryPatch('update', 'Belongs to', ['[[x]]'])
|
||||
expect(result.patch).toEqual({ belongsTo: ['[[x]]'] })
|
||||
expect(result.relationshipPatch).toEqual({ 'Belongs to': ['[[x]]'] })
|
||||
})
|
||||
|
||||
it('returns empty object for unknown keys', () => {
|
||||
expect(frontmatterToEntryPatch('update', 'custom_field', 'value')).toEqual({})
|
||||
it('returns empty patch for unknown non-wikilink keys', () => {
|
||||
const result = frontmatterToEntryPatch('update', 'custom_field', 'value')
|
||||
expect(result.patch).toEqual({})
|
||||
// Non-wikilink value → no relationship change
|
||||
expect(result.relationshipPatch).toBeNull()
|
||||
})
|
||||
|
||||
it('produces relationship patch for wikilink values on unknown keys', () => {
|
||||
const result = frontmatterToEntryPatch('update', 'Notes', ['[[note-a]]', '[[note-b|Note B]]'])
|
||||
expect(result.patch).toEqual({})
|
||||
expect(result.relationshipPatch).toEqual({ Notes: ['[[note-a]]', '[[note-b|Note B]]'] })
|
||||
})
|
||||
|
||||
it('produces relationship patch for single wikilink string', () => {
|
||||
const result = frontmatterToEntryPatch('update', 'Owner', '[[person/alice]]')
|
||||
expect(result.patch).toEqual({})
|
||||
expect(result.relationshipPatch).toEqual({ Owner: ['[[person/alice]]'] })
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -377,12 +397,46 @@ describe('frontmatterToEntryPatch', () => {
|
||||
] as [string, Partial<VaultEntry>][])(
|
||||
'maps delete of %s to null/default',
|
||||
(key, expected) => {
|
||||
expect(frontmatterToEntryPatch('delete', key)).toEqual(expected)
|
||||
expect(frontmatterToEntryPatch('delete', key).patch).toEqual(expected)
|
||||
},
|
||||
)
|
||||
|
||||
it('returns empty object for unknown key on delete', () => {
|
||||
expect(frontmatterToEntryPatch('delete', 'unknown_key')).toEqual({})
|
||||
it('returns empty patch for unknown key on delete, with relationship removal', () => {
|
||||
const result = frontmatterToEntryPatch('delete', 'unknown_key')
|
||||
expect(result.patch).toEqual({})
|
||||
expect(result.relationshipPatch).toEqual({ unknown_key: null })
|
||||
})
|
||||
|
||||
it('delete of known key also produces relationship removal', () => {
|
||||
const result = frontmatterToEntryPatch('delete', 'status')
|
||||
expect(result.patch).toEqual({ status: null })
|
||||
expect(result.relationshipPatch).toEqual({ status: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyRelationshipPatch', () => {
|
||||
it('adds new relationship key', () => {
|
||||
const existing = { 'Belongs to': ['[[eng]]'] }
|
||||
const result = applyRelationshipPatch(existing, { Notes: ['[[note-a]]', '[[note-b]]'] })
|
||||
expect(result).toEqual({ 'Belongs to': ['[[eng]]'], Notes: ['[[note-a]]', '[[note-b]]'] })
|
||||
})
|
||||
|
||||
it('overwrites existing relationship key', () => {
|
||||
const existing = { Notes: ['[[old]]'] }
|
||||
const result = applyRelationshipPatch(existing, { Notes: ['[[new-a]]', '[[new-b]]'] })
|
||||
expect(result).toEqual({ Notes: ['[[new-a]]', '[[new-b]]'] })
|
||||
})
|
||||
|
||||
it('removes relationship key when value is null', () => {
|
||||
const existing = { Notes: ['[[a]]'], Owner: ['[[alice]]'] }
|
||||
const result = applyRelationshipPatch(existing, { Notes: null })
|
||||
expect(result).toEqual({ Owner: ['[[alice]]'] })
|
||||
})
|
||||
|
||||
it('does not mutate the original map', () => {
|
||||
const existing = { Notes: ['[[a]]'] }
|
||||
applyRelationshipPatch(existing, { Notes: ['[[b]]'] })
|
||||
expect(existing).toEqual({ Notes: ['[[a]]'] })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -117,8 +117,8 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
|
||||
const runFrontmatterOp = useCallback(
|
||||
(op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue, options?: FrontmatterOpOptions) =>
|
||||
runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage }, options),
|
||||
[updateTabContent, updateEntry, setToastMessage],
|
||||
runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) }, options),
|
||||
[updateTabContent, updateEntry, setToastMessage, entries],
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -324,6 +324,50 @@ describe('buildRelationshipGroups', () => {
|
||||
const groups = buildRelationshipGroups(entity, [entity, linker])
|
||||
expect(groups.find((g) => g.label === 'Backlinks')!.entries[0].title).toBe('Linker')
|
||||
})
|
||||
|
||||
it('resolves all entries in a large Notes relationship (regression: No Code)', () => {
|
||||
// Simulates the No Code topic note with 32 Notes, 2 Referred by Data, 1 Belongs to
|
||||
const noteRefs = [
|
||||
'8020', 'airdev-build-hub', 'airdev-leader', 'budibase', 'bullet-launch',
|
||||
'canvas', 'chameleon', 'felt', 'flutterflow', 'framer-ai',
|
||||
'jumpstart', 'mailparser', 'make', 'michele-sampieri', 'n8n-a',
|
||||
'n8n-ai', 'nocodey', 'outseta', 'lemon-squeezy', 'retool',
|
||||
'rise-no-code', 'scene', 'scrapingbee', 'softr', 'superblocks',
|
||||
'superwall', 'tails', 'supabase', 'varun-anand', 'xano',
|
||||
'directus', 'framer-design',
|
||||
]
|
||||
const noteEntries = noteRefs.map((slug, i) => makeEntry({
|
||||
path: `/Laputa/${slug}.md`, filename: `${slug}.md`, title: `Title ${slug}`,
|
||||
modifiedAt: 1700000000 - i * 100,
|
||||
}))
|
||||
const engineering = makeEntry({
|
||||
path: '/Laputa/engineering.md', filename: 'engineering.md', title: 'Engineering',
|
||||
modifiedAt: 1700000000,
|
||||
})
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/no-code.md', filename: 'no-code.md', title: 'No Code',
|
||||
isA: 'Topic',
|
||||
relationships: {
|
||||
'Belongs to': ['[[engineering|Engineering]]'],
|
||||
Notes: noteRefs.map((slug) => `[[${slug}|Title ${slug}]]`),
|
||||
'Referred by Data': ['[[michele-sampieri|Michele Sampieri]]', '[[varun-anand|Varun Anand]]'],
|
||||
},
|
||||
})
|
||||
const allEntries = [entity, engineering, ...noteEntries]
|
||||
const groups = buildRelationshipGroups(entity, allEntries)
|
||||
|
||||
const belongsGroup = groups.find((g) => g.label === 'Belongs to')
|
||||
expect(belongsGroup).toBeDefined()
|
||||
expect(belongsGroup!.entries).toHaveLength(1)
|
||||
|
||||
const notesGroup = groups.find((g) => g.label === 'Notes')
|
||||
expect(notesGroup).toBeDefined()
|
||||
expect(notesGroup!.entries).toHaveLength(32)
|
||||
|
||||
// michele-sampieri and varun-anand already consumed by Notes → Referred by Data has 0 new
|
||||
const referredGroup = groups.find((g) => g.label === 'Referred by Data')
|
||||
expect(referredGroup).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortComparator — custom properties', () => {
|
||||
|
||||
Reference in New Issue
Block a user