feat: warn on missing note types
This commit is contained in:
31
src/App.tsx
31
src/App.tsx
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
60
src/components/inspector/useInspectorPropertyActions.ts
Normal file
60
src/components/inspector/useInspectorPropertyActions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
61
tests/smoke/missing-type-warning.spec.ts
Normal file
61
tests/smoke/missing-type-warning.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user