feat: warn on missing note types

This commit is contained in:
lucaronin
2026-04-17 00:44:14 +02:00
parent 072591ad59
commit a84f3c93ca
16 changed files with 732 additions and 268 deletions

View File

@@ -29,6 +29,7 @@ import { useVaultLoader } from './hooks/useVaultLoader'
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
import { useSettings } from './hooks/useSettings'
import { useNoteActions } from './hooks/useNoteActions'
import { slugify } from './hooks/useNoteCreation'
import { useCommitFlow } from './hooks/useCommitFlow'
import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
import { useViewMode, type ViewMode } from './hooks/useViewMode'
@@ -718,7 +719,34 @@ function App() {
const handleCreateType = useCallback((name: string) => {
notes.handleCreateType(name)
setToastMessage(`Type "${name}" created`)
}, [notes])
}, [notes, setToastMessage])
const handleCreateMissingType = useCallback(async (path: string, missingType: string, nextTypeName: string) => {
const trimmed = nextTypeName.trim()
if (!trimmed) return
const targetFilename = `${slugify(trimmed)}.md`
const exactType = vault.entries.find((entry) => entry.isA === 'Type' && entry.title === trimmed)
const slugMatch = vault.entries.find((entry) => entry.isA === 'Type' && slugify(entry.title) === slugify(trimmed))
const filenameCollision = vault.entries.find((entry) => entry.filename.toLowerCase() === targetFilename)
const resolvedTypeName = exactType?.title ?? slugMatch?.title ?? trimmed
if (filenameCollision && filenameCollision.isA !== 'Type') {
setToastMessage(`Cannot create type "${trimmed}" because ${targetFilename} already exists`)
throw new Error(`Type filename collision for ${targetFilename}`)
}
if (!exactType && !slugMatch) {
await notes.createTypeEntrySilent(trimmed)
}
await notes.handleUpdateFrontmatter(path, 'type', resolvedTypeName)
setToastMessage(
resolvedTypeName === missingType
? `Type "${resolvedTypeName}" created`
: `Type set to "${resolvedTypeName}"`,
)
}, [notes, setToastMessage, vault.entries])
const handleCreateOrUpdateView = useCallback(async (definition: import('./types').ViewDefinition) => {
const editing = dialogs.editingView
@@ -1060,6 +1088,7 @@ function App() {
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
onDeleteProperty={notes.handleDeleteProperty}
onAddProperty={notes.handleAddProperty}
onCreateMissingType={handleCreateMissingType}
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
onInitializeProperties={handleInitializeProperties}
showAIChat={dialogs.showAIChat}

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { CreateTypeDialog } from './CreateTypeDialog'
@@ -34,7 +34,16 @@ describe('CreateTypeDialog', () => {
fireEvent.change(screen.getByPlaceholderText('e.g. Recipe, Book, Habit...'), { target: { value: ' Recipe ' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onCreate).toHaveBeenCalledWith('Recipe')
expect(onClose).toHaveBeenCalled()
})
it('closes after create completes', async () => {
const onClose = vi.fn()
render(<CreateTypeDialog open={true} onClose={onClose} onCreate={() => Promise.resolve()} />)
fireEvent.change(screen.getByPlaceholderText('e.g. Recipe, Book, Habit...'), { target: { value: 'Recipe' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(onClose).toHaveBeenCalled())
})
it('calls onClose when Cancel is clicked', () => {
@@ -60,4 +69,10 @@ describe('CreateTypeDialog', () => {
fireEvent.submit(screen.getByPlaceholderText('e.g. Recipe, Book, Habit...').closest('form')!)
expect(onCreate).not.toHaveBeenCalled()
})
it('prefills the name when initialName is provided', () => {
render(<CreateTypeDialog open={true} onClose={() => {}} onCreate={() => {}} initialName="Hotel" />)
expect(screen.getByDisplayValue('Hotel')).toBeInTheDocument()
})
})

View File

@@ -1,63 +1,77 @@
import { useState, useRef, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { useState } from 'react'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
interface CreateTypeDialogProps {
open: boolean
onClose: () => void
onCreate: (name: string) => void
onCreate: (name: string) => void | Promise<void>
initialName?: string
}
export function CreateTypeDialog({ open, onClose, onCreate }: CreateTypeDialogProps) {
const [name, setName] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
interface CreateTypeDialogFormProps {
initialName: string
onClose: () => void
onCreate: (name: string) => void | Promise<void>
}
useEffect(() => {
if (open) {
setName('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open])
function CreateTypeDialogForm({ initialName, onClose, onCreate }: CreateTypeDialogFormProps) {
const [name, setName] = useState(initialName)
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const trimmed = name.trim()
if (!trimmed) return
onCreate(trimmed)
await onCreate(trimmed)
onClose()
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Type Name
</label>
<Input
autoFocus
placeholder="e.g. Recipe, Book, Habit..."
value={name}
onChange={(e) => setName(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
/>
<p className="text-xs text-muted-foreground">
Creates a type document. Its properties become defaults for new docs of this type.
</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={!name.trim()}>
Create
</Button>
</DialogFooter>
</form>
)
}
export function CreateTypeDialog({ open, onClose, onCreate, initialName = '' }: CreateTypeDialogProps) {
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[380px]">
<DialogHeader>
<DialogTitle>Create New Type</DialogTitle>
<DialogDescription>
Create a type document so notes of this type can inherit templates and metadata.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Type Name
</label>
<Input
ref={inputRef}
placeholder="e.g. Recipe, Book, Habit..."
value={name}
onChange={(e) => setName(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Creates a type document. Its properties become defaults for new docs of this type.
</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={!name.trim()}>
Create
</Button>
</DialogFooter>
</form>
<CreateTypeDialogForm
key={initialName}
initialName={initialName}
onClose={onClose}
onCreate={onCreate}
/>
</DialogContent>
</Dialog>
)

View File

@@ -1,8 +1,10 @@
import type { ReactElement } from 'react'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react'
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
import { FOCUS_NOTE_ICON_PROPERTY_EVENT } from './noteIconPropertyEvents'
import type { VaultEntry } from '../types'
import { TooltipProvider } from '@/components/ui/tooltip'
beforeAll(() => {
global.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }
@@ -54,6 +56,10 @@ function hasSuggestedSlot(label: string): boolean {
.some((node) => node.textContent?.includes(label))
}
function render(ui: ReactElement) {
return rtlRender(ui, { wrapper: TooltipProvider })
}
describe('DynamicPropertiesPanel system metadata', () => {
const onAddProperty = vi.fn()

View File

@@ -5,6 +5,7 @@ import { DynamicPropertiesPanel, containsWikilinks } from './DynamicPropertiesPa
import type { VaultEntry } from '../types'
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
import { initDisplayModeOverrides } from '../utils/propertyTypes'
import { TooltipProvider } from '@/components/ui/tooltip'
// Radix Select needs ResizeObserver and pointer/scroll APIs in JSDOM
beforeAll(() => {
@@ -57,12 +58,14 @@ const renderPanel = ({
...props
}: RenderPanelOptions = {}) =>
render(
<DynamicPropertiesPanel
entry={entry}
content={content}
frontmatter={frontmatter}
{...props}
/>,
<TooltipProvider>
<DynamicPropertiesPanel
entry={entry}
content={content}
frontmatter={frontmatter}
{...props}
/>
</TooltipProvider>,
)
describe('containsWikilinks', () => {
@@ -258,12 +261,14 @@ describe('DynamicPropertiesPanel', () => {
it('handles navigating to type via click in read-only mode', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry({ isA: 'Project' })}
content=""
frontmatter={{}}
onNavigate={onNavigate}
/>
<TooltipProvider>
<DynamicPropertiesPanel
entry={makeEntry({ isA: 'Project' })}
content=""
frontmatter={{}}
onNavigate={onNavigate}
/>
</TooltipProvider>,
)
fireEvent.click(screen.getByText('Project'))
expect(onNavigate).toHaveBeenCalledWith('project')
@@ -322,6 +327,41 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByRole('option', { name: 'CustomType' })).toBeInTheDocument()
})
it('shows a missing-type warning with keyboard-accessible help and creation flow', async () => {
const onCreateMissingType = vi.fn().mockResolvedValue(undefined)
renderPanel({
entry: makeEntry({ isA: 'Hotel' }),
entries: typeEntries,
onUpdateProperty,
onCreateMissingType,
})
const warningButton = screen.getByTestId('missing-type-warning')
expect(warningButton).toBeInTheDocument()
fireEvent.focus(warningButton)
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent('There is no type file for this type')
})
fireEvent.click(warningButton)
expect(screen.getByDisplayValue('Hotel')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(onCreateMissingType).toHaveBeenCalledWith('Hotel'))
})
it('does not show a missing-type warning when the type already exists', () => {
renderPanel({
entry: makeEntry({ isA: 'Project' }),
entries: typeEntries,
onUpdateProperty,
})
expect(screen.queryByTestId('missing-type-warning')).not.toBeInTheDocument()
})
it('shows None placeholder when entry has no type', () => {
renderPanel({
entry: makeEntry({ isA: null }),

View File

@@ -111,6 +111,12 @@ function getSuggestedDisplayMode(key: string): PropertyDisplayMode {
return SUGGESTED_PROPERTY_MODES[key] ?? 'text'
}
function resolveMissingTypeName(entryIsA: string | null | undefined, availableTypes: string[]): string | null {
const trimmed = entryIsA?.trim()
if (!trimmed) return null
return availableTypes.includes(trimmed) ? null : trimmed
}
function SuggestedPropertySlot({ label, displayMode, onAdd }: {
label: string
displayMode: PropertyDisplayMode
@@ -223,7 +229,7 @@ function useSuggestedPropertyActions({
export function DynamicPropertiesPanel({
entry, frontmatter, entries,
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate, onCreateMissingType,
}: {
entry: VaultEntry
content?: string | null
@@ -233,6 +239,7 @@ export function DynamicPropertiesPanel({
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onNavigate?: (target: string) => void
onCreateMissingType?: (typeName: string) => void | Promise<void>
}) {
const {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
@@ -240,6 +247,7 @@ export function DynamicPropertiesPanel({
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty })
const [pendingSuggestedKey, setPendingSuggestedKey] = useState<string | null>(null)
const missingTypeName = useMemo(() => resolveMissingTypeName(entry.isA, availableTypes), [entry.isA, availableTypes])
const existingKeys = useMemo(() => getExistingPropertyKeys(propertyEntries, frontmatter), [propertyEntries, frontmatter])
const missingSuggested = useMemo(
@@ -261,7 +269,17 @@ export function DynamicPropertiesPanel({
return (
<div className="flex flex-col gap-3">
<div className="grid min-w-0 gap-x-2 gap-y-1.5" style={PROPERTY_PANEL_GRID_STYLE}>
<TypeSelector isA={entry.isA} customColorKey={customColorKey} availableTypes={availableTypes} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} onUpdateProperty={onUpdateProperty} onNavigate={onNavigate} />
<TypeSelector
isA={entry.isA}
customColorKey={customColorKey}
availableTypes={availableTypes}
typeColorKeys={typeColorKeys}
typeIconKeys={typeIconKeys}
onUpdateProperty={onUpdateProperty}
onNavigate={onNavigate}
missingTypeName={missingTypeName}
onCreateMissingType={onCreateMissingType}
/>
{propertyEntries.map(([key, value]) => (
<PropertyRow
key={key} propKey={key} value={value}

View File

@@ -1,5 +1,5 @@
import { render, screen, fireEvent, act } from '@testing-library/react'
import type { ComponentProps, PropsWithChildren } from 'react'
import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react'
import type { ComponentProps, PropsWithChildren, ReactElement } from 'react'
import { describe, it, expect, vi } from 'vitest'
Object.defineProperty(window, 'matchMedia', {
@@ -93,9 +93,14 @@ import { Editor } from './Editor'
import { applyPendingRawExitContent } from './editorRawModeSync'
import type { VaultEntry } from '../types'
import { bindVaultConfigStore, resetVaultConfigStore } from '../utils/vaultConfigStore'
import { TooltipProvider } from '@/components/ui/tooltip'
type EditorComponentProps = ComponentProps<typeof Editor>
function render(ui: ReactElement) {
return rtlRender(ui, { wrapper: TooltipProvider })
}
const mockEntry: VaultEntry = {
path: '/vault/project/test.md',
filename: 'test.md',

View File

@@ -51,6 +51,7 @@ interface EditorProps {
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
showAIChat?: boolean
@@ -229,7 +230,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
onInspectorResize,
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onInitializeProperties,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
@@ -315,6 +316,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
onUpdateFrontmatter={onUpdateFrontmatter}
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onCreateMissingType={onCreateMissingType}
onCreateAndOpenNote={onCreateAndOpenNote}
onInitializeProperties={onInitializeProperties}
onToggleRawEditor={handleToggleRawExclusive}

View File

@@ -24,6 +24,7 @@ interface EditorRightPanelProps {
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
onToggleRawEditor?: () => void
@@ -39,7 +40,7 @@ export function EditorRightPanel({
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
onFileCreated, onFileModified, onVaultChanged,
}: EditorRightPanelProps) {
if (showAIChat) {
@@ -87,6 +88,7 @@ export function EditorRightPanel({
onUpdateFrontmatter={onUpdateFrontmatter}
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onCreateMissingType={onCreateMissingType}
onCreateAndOpenNote={onCreateAndOpenNote}
onInitializeProperties={onInitializeProperties}
onToggleRawEditor={onToggleRawEditor}

View File

@@ -1,8 +1,13 @@
import type { ComponentProps } from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import type { ComponentProps, ReactElement } from 'react'
import { render as rtlRender, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { Inspector } from './Inspector'
import type { VaultEntry, GitCommit } from '../types'
import { TooltipProvider } from '@/components/ui/tooltip'
function render(ui: ReactElement) {
return rtlRender(ui, { wrapper: TooltipProvider })
}
const mockEntry: VaultEntry = {
path: '/vault/project/test.md',

View File

@@ -1,4 +1,4 @@
import { useMemo, useCallback } from 'react'
import { useMemo } from 'react'
import type { VaultEntry, GitCommit } from '../types'
import { cn } from '@/lib/utils'
import { Separator } from './ui/separator'
@@ -14,6 +14,7 @@ import {
} from './InspectorPanels'
import { EmptyInspector, InitializePropertiesPrompt, InspectorHeader, InvalidFrontmatterNotice } from './inspector/InspectorChrome'
import { useBacklinks, useReferencedBy } from './inspector/useInspectorData'
import { useInspectorPropertyActions } from './inspector/useInspectorPropertyActions'
export type FrontmatterValue = string | number | boolean | string[] | null
@@ -30,11 +31,136 @@ interface InspectorProps {
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
onToggleRawEditor?: () => void
}
function buildTypeEntryMap(entries: VaultEntry[]): Record<string, VaultEntry> {
const map: Record<string, VaultEntry> = {}
for (const candidate of entries) {
if (candidate.isA === 'Type') map[candidate.title] = candidate
}
return map
}
function ValidFrontmatterPanels({
entry,
entries,
frontmatter,
typeEntryMap,
vaultPath,
referencedBy,
onNavigate,
onCreateAndOpenNote,
onUpdateProperty,
onDeleteProperty,
onAddProperty,
onCreateMissingType,
}: {
entry: VaultEntry
entries: VaultEntry[]
frontmatter: ReturnType<typeof parseFrontmatter>
typeEntryMap: Record<string, VaultEntry>
vaultPath?: string
referencedBy: VaultEntry[]
onNavigate: (target: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onCreateMissingType?: (typeName: string) => Promise<void>
}) {
return (
<>
<DynamicPropertiesPanel
entry={entry}
frontmatter={frontmatter}
entries={entries}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onNavigate={onNavigate}
onCreateMissingType={onCreateMissingType}
/>
<Separator data-testid="inspector-properties-relationships-separator" />
<DynamicRelationshipsPanel
frontmatter={frontmatter}
entries={entries}
typeEntryMap={typeEntryMap}
vaultPath={vaultPath}
onNavigate={onNavigate}
onAddProperty={onAddProperty}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
<InstancesPanel entry={entry} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<ReferencedByPanel items={referencedBy} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
</>
)
}
function PrimaryInspectorPanel({
entry,
frontmatterState,
frontmatter,
entries,
typeEntryMap,
vaultPath,
referencedBy,
onNavigate,
onToggleRawEditor,
onInitializeProperties,
onCreateAndOpenNote,
onUpdateProperty,
onDeleteProperty,
onAddProperty,
onCreateMissingType,
}: {
entry: VaultEntry
frontmatterState: ReturnType<typeof detectFrontmatterState>
frontmatter: ReturnType<typeof parseFrontmatter>
entries: VaultEntry[]
typeEntryMap: Record<string, VaultEntry>
vaultPath?: string
referencedBy: VaultEntry[]
onNavigate: (target: string) => void
onToggleRawEditor?: () => void
onInitializeProperties?: (path: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onCreateMissingType?: (typeName: string) => Promise<void>
}) {
if (frontmatterState === 'valid') {
return (
<ValidFrontmatterPanels
entry={entry}
entries={entries}
frontmatter={frontmatter}
typeEntryMap={typeEntryMap}
vaultPath={vaultPath}
referencedBy={referencedBy}
onNavigate={onNavigate}
onCreateAndOpenNote={onCreateAndOpenNote}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onCreateMissingType={onCreateMissingType}
/>
)
}
if (frontmatterState === 'invalid') {
return onToggleRawEditor ? <InvalidFrontmatterNotice onFix={onToggleRawEditor} /> : null
}
return onInitializeProperties ? <InitializePropertiesPrompt onClick={() => onInitializeProperties(entry.path)} /> : null
}
export function Inspector({
collapsed,
onToggle,
@@ -48,6 +174,7 @@ export function Inspector({
onUpdateFrontmatter,
onDeleteProperty,
onAddProperty,
onCreateMissingType,
onCreateAndOpenNote,
onInitializeProperties,
onToggleRawEditor,
@@ -56,25 +183,19 @@ export function Inspector({
const backlinks = useBacklinks(entry, entries, referencedBy)
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
const frontmatterState = useMemo(() => detectFrontmatterState(content), [content])
const typeEntryMap = useMemo(() => {
const map: Record<string, VaultEntry> = {}
for (const candidate of entries) {
if (candidate.isA === 'Type') map[candidate.title] = candidate
}
return map
}, [entries])
const handleUpdateProperty = useCallback((key: string, value: FrontmatterValue) => {
if (entry && onUpdateFrontmatter) onUpdateFrontmatter(entry.path, key, value)
}, [entry, onUpdateFrontmatter])
const handleDeleteProperty = useCallback((key: string) => {
if (entry && onDeleteProperty) onDeleteProperty(entry.path, key)
}, [entry, onDeleteProperty])
const handleAddProperty = useCallback((key: string, value: FrontmatterValue) => {
if (entry && onAddProperty) onAddProperty(entry.path, key, value)
}, [entry, onAddProperty])
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const {
handleUpdateProperty,
handleDeleteProperty,
handleAddProperty,
handleCreateMissingType,
} = useInspectorPropertyActions({
entry,
onUpdateFrontmatter,
onDeleteProperty,
onAddProperty,
onCreateMissingType,
})
return (
<aside className={cn('flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground transition-[width] duration-200', collapsed && '!w-10 !min-w-10')}>
@@ -83,37 +204,23 @@ export function Inspector({
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-3">
{entry ? (
<>
{frontmatterState === 'valid' ? (
<>
<DynamicPropertiesPanel
entry={entry}
frontmatter={frontmatter}
entries={entries}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onNavigate={onNavigate}
/>
<Separator data-testid="inspector-properties-relationships-separator" />
<DynamicRelationshipsPanel
frontmatter={frontmatter}
entries={entries}
typeEntryMap={typeEntryMap}
vaultPath={vaultPath}
onNavigate={onNavigate}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
<InstancesPanel entry={entry} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<ReferencedByPanel items={referencedBy} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
</>
) : frontmatterState === 'invalid' ? (
onToggleRawEditor && <InvalidFrontmatterNotice onFix={onToggleRawEditor} />
) : (
onInitializeProperties && <InitializePropertiesPrompt onClick={() => onInitializeProperties(entry.path)} />
)}
<PrimaryInspectorPanel
entry={entry}
frontmatterState={frontmatterState}
frontmatter={frontmatter}
entries={entries}
typeEntryMap={typeEntryMap}
vaultPath={vaultPath}
referencedBy={referencedBy}
onNavigate={onNavigate}
onToggleRawEditor={onToggleRawEditor}
onInitializeProperties={onInitializeProperties}
onCreateAndOpenNote={onCreateAndOpenNote}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onCreateMissingType={onCreateMissingType ? handleCreateMissingType : undefined}
/>
{backlinks.length > 0 && <Separator />}
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
<Separator />

View File

@@ -1,12 +1,14 @@
import { CaretUpDown, Check, StackSimple } from '@phosphor-icons/react'
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react'
import { CaretUpDown, Check, StackSimple, WarningCircle } from '@phosphor-icons/react'
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react'
import type { FrontmatterValue } from './Inspector'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { getTypeIcon } from './NoteItem'
import { CreateTypeDialog } from './CreateTypeDialog'
import { PROPERTY_CHIP_STYLE } from './propertyChipStyles'
import {
PROPERTY_PANEL_LABEL_CLASS_NAME,
@@ -17,6 +19,7 @@ import {
const TYPE_NONE = '__none__'
const MIN_POPOVER_WIDTH = 220
const OPEN_COMBOBOX_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Enter', ' '])
const MISSING_TYPE_TOOLTIP = 'There is no type file for this type'
interface TypeSelectorItemProps {
type: string
@@ -110,7 +113,87 @@ function TypeRowLabel() {
)
}
function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null; customColorKey?: string | null; onNavigate?: (target: string) => void }) {
function MissingTypeWarning({
missingTypeName,
onCreateMissingType,
}: {
missingTypeName: string
onCreateMissingType?: (typeName: string) => void | Promise<void>
}) {
const [dialogOpen, setDialogOpen] = useState(false)
const canCreateMissingType = Boolean(onCreateMissingType)
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'h-auto shrink-0 gap-1 rounded-full border border-amber-300/70 px-2 py-1 text-[11px] font-medium text-amber-900 shadow-none hover:bg-amber-100',
!canCreateMissingType && 'cursor-default',
)}
data-testid="missing-type-warning"
aria-label={`Missing type ${missingTypeName}. ${MISSING_TYPE_TOOLTIP}`}
onClick={canCreateMissingType ? () => setDialogOpen(true) : undefined}
>
<WarningCircle size={14} aria-hidden="true" />
Missing type
</Button>
</TooltipTrigger>
<TooltipContent>{MISSING_TYPE_TOOLTIP}</TooltipContent>
</Tooltip>
{canCreateMissingType && (
<CreateTypeDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onCreate={async (typeName) => {
await onCreateMissingType?.(typeName)
}}
initialName={missingTypeName}
/>
)}
</>
)
}
function TypeRowValue({
children,
missingTypeName,
onCreateMissingType,
}: {
children: ReactNode
missingTypeName?: string | null
onCreateMissingType?: (typeName: string) => void | Promise<void>
}) {
return (
<div className="flex min-w-0 items-center justify-end gap-1">
<div className="min-w-0">{children}</div>
{missingTypeName && (
<MissingTypeWarning
missingTypeName={missingTypeName}
onCreateMissingType={onCreateMissingType}
/>
)}
</div>
)
}
function ReadOnlyType({
isA,
customColorKey,
onNavigate,
missingTypeName,
onCreateMissingType,
}: {
isA?: string | null
customColorKey?: string | null
onNavigate?: (target: string) => void
missingTypeName?: string | null
onCreateMissingType?: (typeName: string) => void | Promise<void>
}) {
if (!isA) return null
return (
<div
@@ -118,7 +201,7 @@ function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null
style={PROPERTY_PANEL_ROW_STYLE}
>
<TypeRowLabel />
<div className="min-w-0">
<TypeRowValue missingTypeName={missingTypeName} onCreateMissingType={onCreateMissingType}>
{onNavigate ? (
<button
className="min-w-0 max-w-full truncate border-none cursor-pointer ring-inset hover:ring-1 hover:ring-current"
@@ -134,7 +217,7 @@ function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null
) : (
<span className="text-[12px] text-secondary-foreground">{isA}</span>
)}
</div>
</TypeRowValue>
</div>
)
}
@@ -147,15 +230,36 @@ interface TypeSelectorProps {
typeIconKeys: Record<string, string | null>
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onNavigate?: (target: string) => void
missingTypeName?: string | null
onCreateMissingType?: (typeName: string) => void | Promise<void>
}
export function TypeSelector({ onUpdateProperty, ...props }: TypeSelectorProps) {
if (!onUpdateProperty) return <ReadOnlyType isA={props.isA} customColorKey={props.customColorKey} onNavigate={props.onNavigate} />
if (!onUpdateProperty) {
return (
<ReadOnlyType
isA={props.isA}
customColorKey={props.customColorKey}
onNavigate={props.onNavigate}
missingTypeName={props.missingTypeName}
onCreateMissingType={props.onCreateMissingType}
/>
)
}
return <EditableTypeSelector {...props} onUpdateProperty={onUpdateProperty} />
}
function EditableTypeSelector({ isA, customColorKey, availableTypes, typeColorKeys, typeIconKeys, onUpdateProperty }: Omit<TypeSelectorProps, 'onUpdateProperty'> & {
function EditableTypeSelector({
isA,
customColorKey,
availableTypes,
typeColorKeys,
typeIconKeys,
missingTypeName,
onCreateMissingType,
onUpdateProperty,
}: Omit<TypeSelectorProps, 'onUpdateProperty'> & {
onUpdateProperty: (key: string, value: FrontmatterValue) => void
}) {
const currentValue = isA ?? TYPE_NONE
@@ -279,100 +383,102 @@ function EditableTypeSelector({ isA, customColorKey, availableTypes, typeColorKe
data-testid="type-selector"
>
<TypeRowLabel />
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverAnchor asChild>
<div ref={rootRef} className="min-w-0">
<Button
type="button"
variant="ghost"
size="sm"
role="combobox"
aria-controls={listboxId}
aria-expanded={open}
aria-haspopup="listbox"
className={cn(
'h-auto max-w-full justify-between gap-1 border-none px-2 shadow-none ring-inset [&_svg]:text-current',
isA ? 'hover:ring-1 hover:ring-current' : 'bg-muted hover:bg-muted/80',
<TypeRowValue missingTypeName={missingTypeName} onCreateMissingType={onCreateMissingType}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverAnchor asChild>
<div ref={rootRef} className="min-w-0">
<Button
type="button"
variant="ghost"
size="sm"
role="combobox"
aria-controls={listboxId}
aria-expanded={open}
aria-haspopup="listbox"
className={cn(
'h-auto max-w-full justify-between gap-1 border-none px-2 shadow-none ring-inset [&_svg]:text-current',
isA ? 'hover:ring-1 hover:ring-current' : 'bg-muted hover:bg-muted/80',
)}
style={{
...PROPERTY_CHIP_STYLE,
background: typeLightColor ?? undefined,
color: typeColor ?? undefined,
}}
onPointerDown={handleTriggerPointerDown}
onKeyDown={handleTriggerKeyDown}
>
<span className="flex min-w-0 items-center gap-1 truncate">
<TypeSelectorValue isA={isA} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
</span>
<CaretUpDown size={14} aria-hidden="true" />
</Button>
</div>
</PopoverAnchor>
<PopoverContent
align="start"
side="left"
sideOffset={4}
className="overflow-hidden p-1"
style={{ width: contentWidth }}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<div className="border-b border-border p-1">
<Input
ref={inputRef}
value={query}
placeholder="Search types..."
autoComplete="off"
aria-label="Search types"
className="h-8 text-sm"
data-testid="type-selector-search-input"
onChange={(event) => handleSearchChange(event.target.value)}
onKeyDown={handleSearchKeyDown}
/>
</div>
<div ref={listRef} className="max-h-60 overflow-y-auto p-1">
{options.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
No matching types
</div>
) : (
<div id={listboxId} role="listbox">
{options.map((type, index) => {
const selected = type === currentValue
const highlighted = index === highlightedIndex
return (
<Button
key={type}
type="button"
variant="ghost"
size="sm"
role="option"
aria-selected={selected}
data-index={index}
className={cn(
'h-auto w-full justify-between px-2 py-1.5 text-left font-normal',
highlighted && 'bg-muted',
)}
onMouseEnter={() => setHighlightedIndex(index)}
onClick={() => selectType(type)}
>
{type === TYPE_NONE ? (
<span className="truncate text-muted-foreground">None</span>
) : (
<span className="flex min-w-0 items-center gap-2 truncate">
<TypeSelectorItem type={type} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
</span>
)}
{selected ? <Check size={14} aria-hidden="true" /> : null}
</Button>
)
})}
</div>
)}
style={{
...PROPERTY_CHIP_STYLE,
background: typeLightColor ?? undefined,
color: typeColor ?? undefined,
}}
onPointerDown={handleTriggerPointerDown}
onKeyDown={handleTriggerKeyDown}
>
<span className="flex min-w-0 items-center gap-1 truncate">
<TypeSelectorValue isA={isA} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
</span>
<CaretUpDown size={14} aria-hidden="true" />
</Button>
</div>
</PopoverAnchor>
<PopoverContent
align="start"
side="left"
sideOffset={4}
className="overflow-hidden p-1"
style={{ width: contentWidth }}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<div className="border-b border-border p-1">
<Input
ref={inputRef}
value={query}
placeholder="Search types..."
autoComplete="off"
aria-label="Search types"
className="h-8 text-sm"
data-testid="type-selector-search-input"
onChange={(event) => handleSearchChange(event.target.value)}
onKeyDown={handleSearchKeyDown}
/>
</div>
<div ref={listRef} className="max-h-60 overflow-y-auto p-1">
{options.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
No matching types
</div>
) : (
<div id={listboxId} role="listbox">
{options.map((type, index) => {
const selected = type === currentValue
const highlighted = index === highlightedIndex
return (
<Button
key={type}
type="button"
variant="ghost"
size="sm"
role="option"
aria-selected={selected}
data-index={index}
className={cn(
'h-auto w-full justify-between px-2 py-1.5 text-left font-normal',
highlighted && 'bg-muted',
)}
onMouseEnter={() => setHighlightedIndex(index)}
onClick={() => selectType(type)}
>
{type === TYPE_NONE ? (
<span className="truncate text-muted-foreground">None</span>
) : (
<span className="flex min-w-0 items-center gap-2 truncate">
<TypeSelectorItem type={type} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
</span>
)}
{selected ? <Check size={14} aria-hidden="true" /> : null}
</Button>
)
})}
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
</PopoverContent>
</Popover>
</TypeRowValue>
</div>
)
}

View File

@@ -0,0 +1,60 @@
import { useMemo } from 'react'
import type { VaultEntry } from '../../types'
type FrontmatterValue = string | number | boolean | string[] | null
interface InspectorPropertyActionsConfig {
entry: VaultEntry | null
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
}
function bindEntryAction<TArgs extends unknown[], TResult>(
entry: VaultEntry | null,
action: ((path: string, ...args: TArgs) => TResult) | undefined,
) {
if (!entry || !action) return undefined
return (...args: TArgs) => action(entry.path, ...args)
}
function bindMissingTypeAction(
entry: VaultEntry | null,
action: ((path: string, missingType: string, nextTypeName: string) => Promise<void>) | undefined,
) {
if (!entry?.isA || !action) return undefined
return (nextTypeName: string) => action(entry.path, entry.isA, nextTypeName)
}
export function useInspectorPropertyActions({
entry,
onUpdateFrontmatter,
onDeleteProperty,
onAddProperty,
onCreateMissingType,
}: InspectorPropertyActionsConfig) {
const handleUpdateProperty = useMemo(
() => bindEntryAction<[string, FrontmatterValue], Promise<void>>(entry, onUpdateFrontmatter),
[entry, onUpdateFrontmatter],
)
const handleDeleteProperty = useMemo(
() => bindEntryAction<[string], Promise<void>>(entry, onDeleteProperty),
[entry, onDeleteProperty],
)
const handleAddProperty = useMemo(
() => bindEntryAction<[string, FrontmatterValue], Promise<void>>(entry, onAddProperty),
[entry, onAddProperty],
)
const handleCreateMissingType = useMemo(
() => bindMissingTypeAction(entry, onCreateMissingType),
[entry, onCreateMissingType],
)
return {
handleUpdateProperty,
handleDeleteProperty,
handleAddProperty,
handleCreateMissingType,
}
}

View File

@@ -1,8 +1,10 @@
import type { ComponentProps } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
import { DynamicRelationshipsPanel } from './InspectorPanels'
import type { VaultEntry } from '../types'
import { TooltipProvider } from '@/components/ui/tooltip'
const entry: VaultEntry = {
path: '/vault/note.md',
@@ -37,15 +39,21 @@ const entry: VaultEntry = {
sidebarLabel: null,
}
function renderPropertiesPanel(props: ComponentProps<typeof DynamicPropertiesPanel>) {
return render(
<TooltipProvider>
<DynamicPropertiesPanel {...props} />
</TooltipProvider>,
)
}
describe('property panel shared grid layout', () => {
it('uses a shared fit-content column grid with subgridded rows', () => {
render(
<DynamicPropertiesPanel
entry={entry}
frontmatter={{ VeryLongPropertyName: 'Value', Status: 'Active' }}
onUpdateProperty={vi.fn()}
/>
)
renderPropertiesPanel({
entry,
frontmatter: { VeryLongPropertyName: 'Value', Status: 'Active' },
onUpdateProperty: vi.fn(),
})
const typeRow = screen.getByTestId('type-selector')
const layoutGrid = typeRow.parentElement
@@ -61,29 +69,17 @@ describe('property panel shared grid layout', () => {
})
})
it('keeps suggested property rows on the shared grid', () => {
render(
<DynamicPropertiesPanel
entry={entry}
frontmatter={{}}
onAddProperty={vi.fn()}
/>
)
it('keeps suggested and add-property rows on the shared grid', () => {
renderPropertiesPanel({
entry,
frontmatter: {},
onAddProperty: vi.fn(),
})
screen.getAllByTestId('suggested-property').forEach((row) => {
expect(row.style.gridTemplateColumns).toBe('subgrid')
expect(row.style.gridColumn).toBe('1 / -1')
})
})
it('keeps the add-property row on the shared grid', () => {
render(
<DynamicPropertiesPanel
entry={entry}
frontmatter={{}}
onAddProperty={vi.fn()}
/>
)
const row = screen.getByTestId('add-property-row')
expect(row.style.gridTemplateColumns).toBe('subgrid')
@@ -91,14 +87,12 @@ describe('property panel shared grid layout', () => {
})
it('uses the same fixed icon slot size for type, suggested, and add-property rows', () => {
render(
<DynamicPropertiesPanel
entry={entry}
frontmatter={{}}
onAddProperty={vi.fn()}
onUpdateProperty={vi.fn()}
/>
)
renderPropertiesPanel({
entry,
frontmatter: {},
onAddProperty: vi.fn(),
onUpdateProperty: vi.fn(),
})
expect(screen.getByTestId('type-row-icon-slot')).toHaveClass('size-5')
screen.getAllByTestId('suggested-property-icon-slot').forEach((slot) => {
@@ -108,38 +102,32 @@ describe('property panel shared grid layout', () => {
})
it('renders suggested-property labels in lighter placeholder text', () => {
render(
<DynamicPropertiesPanel
entry={entry}
frontmatter={{}}
onAddProperty={vi.fn()}
/>
)
renderPropertiesPanel({
entry,
frontmatter: {},
onAddProperty: vi.fn(),
})
expect(screen.getByText('Status').parentElement).toHaveClass('text-muted-foreground/40')
expect(screen.getByText('Date').parentElement).toHaveClass('text-muted-foreground/40')
})
it('renders the add-property row in lighter placeholder text', () => {
render(
<DynamicPropertiesPanel
entry={entry}
frontmatter={{}}
onAddProperty={vi.fn()}
/>
)
renderPropertiesPanel({
entry,
frontmatter: {},
onAddProperty: vi.fn(),
})
expect(screen.getByText('Add property').parentElement).toHaveClass('text-muted-foreground/40')
})
it('renders plain text values flush with the shared value column', () => {
render(
<DynamicPropertiesPanel
entry={entry}
frontmatter={{ Owner: 'Luca' }}
onUpdateProperty={vi.fn()}
/>
)
renderPropertiesPanel({
entry,
frontmatter: { Owner: 'Luca' },
onUpdateProperty: vi.fn(),
})
expect(screen.getByText('Luca').parentElement).toHaveClass('px-0')
})

View File

@@ -1,9 +1,11 @@
import type { ReactElement } from 'react'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { fireEvent, render as rtlRender, screen, waitFor, within } from '@testing-library/react'
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
import type { VaultEntry } from '../types'
import { initDisplayModeOverrides } from '../utils/propertyTypes'
import { bindVaultConfigStore, resetVaultConfigStore } from '../utils/vaultConfigStore'
import { TooltipProvider } from '@/components/ui/tooltip'
beforeAll(() => {
global.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }
@@ -41,6 +43,10 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
...overrides,
})
function render(ui: ReactElement) {
return rtlRender(ui, { wrapper: TooltipProvider })
}
describe('property type icon control', () => {
beforeEach(() => {
resetVaultConfigStore()

View File

@@ -0,0 +1,61 @@
import { test, expect } from '@playwright/test'
import fs from 'fs'
import path from 'path'
import { createFixtureVaultCopy, openFixtureVaultDesktopHarness, removeFixtureVaultCopy } from '../helpers/fixtureVault'
let tempVaultDir: string
function missingTypeNotePath(vaultPath: string): string {
return path.join(vaultPath, 'hotel-guide.md')
}
test.describe('Missing type warning', () => {
test.beforeEach(async ({ page }) => {
tempVaultDir = createFixtureVaultCopy()
fs.writeFileSync(
missingTypeNotePath(tempVaultDir),
'---\ntype: Hotel\nstatus: Active\n---\n# Hotel Guide\n\nMissing type test note.\n',
)
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await page.setViewportSize({ width: 1600, height: 900 })
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('lets keyboard users inspect, cancel, and create a missing type from the Properties panel', async ({ page }) => {
await page.getByText('Hotel Guide', { exact: true }).click()
await page.keyboard.press('Control+Shift+i')
const warning = page.getByTestId('missing-type-warning')
await expect(warning).toBeVisible()
await warning.focus()
await expect(page.getByText('There is no type file for this type')).toBeVisible()
await page.keyboard.press('Enter')
const dialog = page.getByRole('dialog')
const typeNameInput = dialog.getByPlaceholder('e.g. Recipe, Book, Habit...')
await expect(dialog).toBeVisible()
await expect(typeNameInput).toHaveValue('Hotel')
await expect(typeNameInput).toBeFocused()
await page.keyboard.press('Escape')
await expect(dialog).not.toBeVisible()
await expect(warning).toBeVisible()
await warning.focus()
await page.keyboard.press('Enter')
await expect(typeNameInput).toBeFocused()
await page.keyboard.press('Enter')
await expect(warning).toHaveCount(0)
await expect(page.getByRole('combobox')).toContainText('Hotel')
await expect.poll(() => fs.existsSync(path.join(tempVaultDir, 'hotel.md'))).toBe(true)
await page.getByText('Alpha Project', { exact: true }).click()
await page.getByText('Hotel Guide', { exact: true }).click()
await expect(page.getByTestId('missing-type-warning')).toHaveCount(0)
})
})