fix: refresh type state after type changes

This commit is contained in:
lucaronin
2026-04-27 03:17:45 +02:00
parent 257cf6ed02
commit f1bed131bf
5 changed files with 144 additions and 10 deletions

View File

@@ -552,6 +552,7 @@ function App() {
unsavedPaths: vault.unsavedPaths,
markContentPending: (path, content) => appSave.contentChangeRef.current(path, content),
onNewNotePersisted: vault.loadModifiedFiles,
onTypeStateChanged: async () => { await vault.reloadVault() },
replaceEntry: vault.replaceEntry,
onFrontmatterPersisted: vault.loadModifiedFiles,
onPathRenamed: (oldPath, newPath) => appSave.trackRenamedPath(oldPath, newPath),

View File

@@ -34,6 +34,8 @@ export interface NoteActionsConfig {
onFrontmatterContentChanged?: (path: string, content: string) => void
/** Called after a frontmatter mutation is fully persisted, including follow-up renames. */
onFrontmatterPersisted?: () => void
/** Called after type files or type assignments change, so derived type surfaces can reload. */
onTypeStateChanged?: () => void | Promise<void>
}
function isTitleKey(key: string): boolean {
@@ -94,6 +96,18 @@ function shouldRenameOnTitleUpdate(key: string, value: FrontmatterValue): value
return isTitleKey(key) && typeof value === 'string' && value !== ''
}
function isTypeFieldKey(key: string): boolean {
const normalized = key.trim().toLowerCase().replace(/\s+/g, '_')
return normalized === 'type' || normalized === 'is_a'
}
async function notifyFrontmatterPersisted(config: NoteActionsConfig, key: string): Promise<void> {
config.onFrontmatterPersisted?.()
if (isTypeFieldKey(key)) {
await config.onTypeStateChanged?.()
}
}
interface NavigateWikilinkParams {
entries: VaultEntry[]
target: string
@@ -192,7 +206,7 @@ async function updateFrontmatterAndMaybeRename({
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
await maybeRenameAfterFrontmatterUpdate({ path, key, value, deps })
config.onFrontmatterPersisted?.()
await notifyFrontmatterPersisted(config, key)
}
function buildTabManagementOptions(
@@ -280,7 +294,7 @@ function useFrontmatterActionHandlers({
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
await notifyFrontmatterPersisted(config, key)
}, [config, runFrontmatterOp])
const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue) => {
@@ -289,7 +303,7 @@ function useFrontmatterActionHandlers({
const newContent = await runFrontmatterOp('update', path, key, value)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
await notifyFrontmatterPersisted(config, key)
}, [config, runFrontmatterOp])
return {

View File

@@ -0,0 +1,105 @@
import { renderHook, act } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { VaultEntry } from '../types'
import { useNoteActions, type NoteActionsConfig } from './useNoteActions'
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('../mock-tauri', () => ({
isTauri: vi.fn(() => false),
addMockEntry: vi.fn(),
updateMockContent: vi.fn(),
trackMockChange: vi.fn(),
mockInvoke: vi.fn().mockResolvedValue(''),
}))
vi.mock('./mockFrontmatterHelpers', () => ({
updateMockFrontmatter: vi.fn().mockReturnValue('---\ntype: Hotel\n---\n'),
deleteMockFrontmatterProperty: vi.fn().mockReturnValue('---\n---\n'),
}))
const baseEntry: VaultEntry = {
path: '/vault/note.md',
filename: 'note.md',
title: 'Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 10,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
template: null,
sort: null,
sidebarLabel: null,
view: null,
visible: null,
properties: {},
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
hasH1: false,
}
function renderActions(overrides: Partial<NoteActionsConfig> = {}) {
const config: NoteActionsConfig = {
addEntry: vi.fn(),
removeEntry: vi.fn(),
entries: [baseEntry],
setToastMessage: vi.fn(),
updateEntry: vi.fn(),
vaultPath: '/vault',
...overrides,
}
return {
...renderHook(() => useNoteActions(config)),
config,
}
}
describe('useNoteActions type state sync', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('refreshes type state after assigning a note type', async () => {
const onTypeStateChanged = vi.fn().mockResolvedValue(undefined)
const { result } = renderActions({ onTypeStateChanged })
await act(async () => {
await result.current.handleUpdateFrontmatter('/vault/note.md', 'type', 'Hotel')
})
expect(onTypeStateChanged).toHaveBeenCalledOnce()
})
it('does not refresh type state for unrelated frontmatter edits', async () => {
const onTypeStateChanged = vi.fn()
const { result } = renderActions({ onTypeStateChanged })
await act(async () => {
await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Active')
})
expect(onTypeStateChanged).not.toHaveBeenCalled()
})
it('refreshes type state after creating a type file', async () => {
const onTypeStateChanged = vi.fn().mockResolvedValue(undefined)
const { result } = renderActions({ onTypeStateChanged })
await act(async () => {
await result.current.handleCreateType('Hotel')
})
expect(onTypeStateChanged).toHaveBeenCalledOnce()
})
})

View File

@@ -310,7 +310,7 @@ describe('resolveNewType', () => {
expect(entry.isA).toBe('Type')
expect(entry.status).toBeNull()
expect(content).toContain('type: Type')
expect(content).not.toContain('# Recipe')
expect(content).toContain('# Recipe')
})
it('uses provided vault path instead of hardcoded path', () => {

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, addMockEntry } from '../mock-tauri'
import { isTauri, addMockEntry, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import { slugifyNoteStem as slugify } from '../utils/noteSlug'
import { resolveEntry } from '../utils/wikilink'
@@ -127,7 +127,7 @@ export interface NewTypeParams {
export function resolveNewType({ typeName, vaultPath }: NewTypeParams): { entry: VaultEntry; content: string } {
const slug = slugify(typeName)
const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title: typeName, type: 'Type', status: null })
return { entry, content: `---\ntype: Type\n---\n` }
return { entry, content: `---\ntype: Type\n---\n\n# ${typeName}\n` }
}
type ResolvedEntry = { entry: VaultEntry; content: string }
@@ -226,7 +226,7 @@ function createPersistFailureMessage(entry: VaultEntry, error: unknown): string
/** Persist a newly created note to disk. Returns a Promise for error handling. */
export function persistNewNote(path: string, content: string): Promise<void> {
if (!isTauri()) return Promise.resolve()
if (!isTauri()) return mockInvoke<void>('save_note_content', { path, content }).then(() => {})
return invoke<void>('create_note_content', { path, content }).then(() => {})
}
@@ -515,6 +515,7 @@ export interface NoteCreationConfig {
unsavedPaths?: Set<string>
markContentPending?: (path: string, content: string) => void
onNewNotePersisted?: () => void
onTypeStateChanged?: () => void | Promise<void>
}
interface CreationTabDeps {
@@ -522,7 +523,17 @@ interface CreationTabDeps {
}
export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTabDeps) {
const { addEntry, removeEntry, entries, setToastMessage, addPendingSave, removePendingSave, vaultPath } = config
const {
addEntry,
removeEntry,
entries,
setToastMessage,
addPendingSave,
removePendingSave,
vaultPath,
onNewNotePersisted,
onTypeStateChanged,
} = config
const { openTabWithContent } = tabDeps
const persistResolvedEntry = useCallback(async (
@@ -535,13 +546,16 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
await persistOptimistic(resolved.entry.path, resolved.content, {
onStart: addPendingSave,
onEnd: removePendingSave,
onPersisted: config.onNewNotePersisted,
onPersisted: onNewNotePersisted,
})
if (resolved.entry.isA === 'Type') {
await onTypeStateChanged?.()
}
} catch (error) {
removeEntry(resolved.entry.path)
throw error
}
}, [openTabWithContent, addEntry, addPendingSave, removePendingSave, config.onNewNotePersisted, removeEntry])
}, [openTabWithContent, addEntry, addPendingSave, removePendingSave, onNewNotePersisted, onTypeStateChanged, removeEntry])
const handleCreateNote = useCallback((title: string, type: string): Promise<boolean> =>
createNamedNote({ entries, vaultPath, setToastMessage, persistResolvedEntry, title, type, creationPath: 'plus_button' }),