fix: sync in-memory entries after property/rename changes

Two propagation gaps fixed:

1. Property changes (type, status, color, etc.) now immediately sync to
   the entries state via frontmatterToEntryPatch(), so sidebar, note list,
   and relations panel reflect updates without restart.

2. After renaming a note, all other open tabs reload their content from
   disk, picking up updated wikilinks immediately.

Design decisions:
- Used key-mapping approach (frontmatterToEntryPatch) instead of a new
  parse_md_file Tauri command — simpler, works in both Tauri and mock
  modes, covers all VaultEntry fields.
- Converted useNoteActions to config object to avoid excess arguments.
- Extracted findWikilinkTarget, reloadTabsAfterRename, renameToastMessage
  as standalone functions to reduce hook complexity (cc 10→9).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-22 17:48:39 +01:00
parent 8b994e7ccf
commit a4bdc359ce
3 changed files with 169 additions and 42 deletions

View File

@@ -9,8 +9,10 @@ import {
buildNoteContent,
resolveNewNote,
resolveNewType,
frontmatterToEntryPatch,
useNoteActions,
} from './useNoteActions'
import type { NoteActionsConfig } from './useNoteActions'
// Mock dependencies
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
@@ -211,20 +213,75 @@ describe('resolveNewType', () => {
})
})
describe('frontmatterToEntryPatch', () => {
it.each([
['is_a', 'Project', { isA: 'Project' }],
['status', 'Done', { status: 'Done' }],
['color', 'red', { color: 'red' }],
['icon', 'star', { icon: 'star' }],
['owner', 'Luca', { owner: 'Luca' }],
['cadence', 'Weekly', { cadence: 'Weekly' }],
['archived', true, { archived: true }],
['trashed', true, { trashed: true }],
['order', 5, { order: 5 }],
] as [string, unknown, Partial<VaultEntry>][])(
'maps %s update to correct entry field',
(key, value, expected) => {
expect(frontmatterToEntryPatch('update', key, value as never)).toEqual(expected)
},
)
it('maps aliases update with array value', () => {
expect(frontmatterToEntryPatch('update', 'aliases', ['A', 'B'])).toEqual({ aliases: ['A', 'B'] })
})
it('maps belongs_to update with array value', () => {
expect(frontmatterToEntryPatch('update', 'belongs_to', ['[[parent]]'])).toEqual({ belongsTo: ['[[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]]'] })
})
it('returns empty object for unknown keys', () => {
expect(frontmatterToEntryPatch('update', 'custom_field', 'value')).toEqual({})
})
it.each([
['status', { status: null }],
['color', { color: null }],
['aliases', { aliases: [] }],
['archived', { archived: false }],
['order', { order: null }],
] as [string, Partial<VaultEntry>][])(
'maps delete of %s to null/default',
(key, expected) => {
expect(frontmatterToEntryPatch('delete', key)).toEqual(expected)
},
)
it('returns empty object for unknown key on delete', () => {
expect(frontmatterToEntryPatch('delete', 'unknown_key')).toEqual({})
})
})
describe('useNoteActions hook', () => {
const addEntry = vi.fn()
const updateContent = vi.fn()
const updateEntry = vi.fn()
const setToastMessage = vi.fn()
const makeConfig = (entries: VaultEntry[] = []): NoteActionsConfig => ({
addEntry, updateContent, entries, setToastMessage, updateEntry,
})
beforeEach(() => {
vi.clearAllMocks()
})
it('handleCreateNote calls addEntry and creates correct entry', () => {
const entries: VaultEntry[] = []
const { result } = renderHook(() =>
useNoteActions(addEntry, updateContent, entries, setToastMessage)
)
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
result.current.handleCreateNote('Test Note', 'Note')
@@ -239,10 +296,7 @@ describe('useNoteActions hook', () => {
})
it('handleCreateType creates type entry', () => {
const entries: VaultEntry[] = []
const { result } = renderHook(() =>
useNoteActions(addEntry, updateContent, entries, setToastMessage)
)
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
result.current.handleCreateType('Recipe')
@@ -256,27 +310,20 @@ describe('useNoteActions hook', () => {
it('handleNavigateWikilink finds entry by title', async () => {
const target = makeEntry({ title: 'Target Note', path: '/vault/note/target.md' })
const entries = [target]
const { result } = renderHook(() =>
useNoteActions(addEntry, updateContent, entries, setToastMessage)
)
const { result } = renderHook(() => useNoteActions(makeConfig([target])))
await act(async () => {
result.current.handleNavigateWikilink('Target Note')
})
// Should set active tab path (via handleSelectNote which loads content)
expect(result.current.activeTabPath).toBe('/vault/note/target.md')
})
it('handleNavigateWikilink warns when target not found', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const entries: VaultEntry[] = []
const { result } = renderHook(() =>
useNoteActions(addEntry, updateContent, entries, setToastMessage)
)
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
result.current.handleNavigateWikilink('Nonexistent')
@@ -286,31 +333,53 @@ describe('useNoteActions hook', () => {
warnSpy.mockRestore()
})
it('handleUpdateFrontmatter calls mock and shows toast on success', async () => {
const entries: VaultEntry[] = []
const { result } = renderHook(() =>
useNoteActions(addEntry, updateContent, entries, setToastMessage)
)
it('handleUpdateFrontmatter calls updateEntry with mapped patch', async () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
await act(async () => {
await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done')
})
expect(updateContent).toHaveBeenCalled()
expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { status: 'Done' })
expect(setToastMessage).toHaveBeenCalledWith('Property updated')
})
it('handleDeleteProperty calls mock and shows toast on success', async () => {
const entries: VaultEntry[] = []
const { result } = renderHook(() =>
useNoteActions(addEntry, updateContent, entries, setToastMessage)
)
it('handleUpdateFrontmatter syncs is_a and color changes to entries', async () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
await act(async () => {
await result.current.handleUpdateFrontmatter('/vault/note.md', 'is_a', 'Project')
})
expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { isA: 'Project' })
vi.clearAllMocks()
await act(async () => {
await result.current.handleUpdateFrontmatter('/vault/note.md', 'color', 'blue')
})
expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { color: 'blue' })
})
it('handleDeleteProperty calls updateEntry with null/default values', async () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
await act(async () => {
await result.current.handleDeleteProperty('/vault/note.md', 'status')
})
expect(updateContent).toHaveBeenCalled()
expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { status: null })
expect(setToastMessage).toHaveBeenCalledWith('Property deleted')
})
it('handleUpdateFrontmatter does not call updateEntry for unknown keys', async () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
await act(async () => {
await result.current.handleUpdateFrontmatter('/vault/note.md', 'custom_field', 'value')
})
expect(updateEntry).not.toHaveBeenCalled()
expect(setToastMessage).toHaveBeenCalledWith('Property updated')
})
})