fix: refresh type state after type changes
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
105
src/hooks/useNoteActions.typeSync.test.tsx
Normal file
105
src/hooks/useNoteActions.typeSync.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
Reference in New Issue
Block a user