diff --git a/src/App.tsx b/src/App.tsx index 05b2e09e..f51374f8 100644 --- a/src/App.tsx +++ b/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} diff --git a/src/components/CreateTypeDialog.test.tsx b/src/components/CreateTypeDialog.test.tsx index 61bc3211..5a51ff89 100644 --- a/src/components/CreateTypeDialog.test.tsx +++ b/src/components/CreateTypeDialog.test.tsx @@ -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( 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( {}} onCreate={() => {}} initialName="Hotel" />) + + expect(screen.getByDisplayValue('Hotel')).toBeInTheDocument() + }) }) diff --git a/src/components/CreateTypeDialog.tsx b/src/components/CreateTypeDialog.tsx index ebfb72d0..56a992f9 100644 --- a/src/components/CreateTypeDialog.tsx +++ b/src/components/CreateTypeDialog.tsx @@ -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 + initialName?: string } -export function CreateTypeDialog({ open, onClose, onCreate }: CreateTypeDialogProps) { - const [name, setName] = useState('') - const inputRef = useRef(null) +interface CreateTypeDialogFormProps { + initialName: string + onClose: () => void + onCreate: (name: string) => void | Promise +} - 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 ( +
+
+ + setName(e.target.value)} + onFocus={(e) => e.currentTarget.select()} + /> +

+ Creates a type document. Its properties become defaults for new docs of this type. +

+
+ + + + +
+ ) +} + +export function CreateTypeDialog({ open, onClose, onCreate, initialName = '' }: CreateTypeDialogProps) { return ( { if (!isOpen) onClose() }}> Create New Type + + Create a type document so notes of this type can inherit templates and metadata. + -
-
- - setName(e.target.value)} - /> -

- Creates a type document. Its properties become defaults for new docs of this type. -

-
- - - - -
+
) diff --git a/src/components/DynamicPropertiesPanel.systemMetadata.test.tsx b/src/components/DynamicPropertiesPanel.systemMetadata.test.tsx index cfb12751..6e1d9d74 100644 --- a/src/components/DynamicPropertiesPanel.systemMetadata.test.tsx +++ b/src/components/DynamicPropertiesPanel.systemMetadata.test.tsx @@ -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() diff --git a/src/components/DynamicPropertiesPanel.test.tsx b/src/components/DynamicPropertiesPanel.test.tsx index ffacba34..b3fb314a 100644 --- a/src/components/DynamicPropertiesPanel.test.tsx +++ b/src/components/DynamicPropertiesPanel.test.tsx @@ -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( - , + + + , ) describe('containsWikilinks', () => { @@ -258,12 +261,14 @@ describe('DynamicPropertiesPanel', () => { it('handles navigating to type via click in read-only mode', () => { render( - + + + , ) 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 }), diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx index 97e17290..e923807b 100644 --- a/src/components/DynamicPropertiesPanel.tsx +++ b/src/components/DynamicPropertiesPanel.tsx @@ -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 }) { 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(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 (
- + {propertyEntries.map(([key, value]) => ( +function render(ui: ReactElement) { + return rtlRender(ui, { wrapper: TooltipProvider }) +} + const mockEntry: VaultEntry = { path: '/vault/project/test.md', filename: 'test.md', diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index b8afa712..c8402f95 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -51,6 +51,7 @@ interface EditorProps { onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise onDeleteProperty?: (path: string, key: string) => Promise onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise + onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise onCreateAndOpenNote?: (title: string) => Promise 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} diff --git a/src/components/EditorRightPanel.tsx b/src/components/EditorRightPanel.tsx index 8310fc5e..b959dc5b 100644 --- a/src/components/EditorRightPanel.tsx +++ b/src/components/EditorRightPanel.tsx @@ -24,6 +24,7 @@ interface EditorRightPanelProps { onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise onDeleteProperty?: (path: string, key: string) => Promise onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise + onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise onCreateAndOpenNote?: (title: string) => Promise 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} diff --git a/src/components/Inspector.test.tsx b/src/components/Inspector.test.tsx index 4e0632a6..716389ba 100644 --- a/src/components/Inspector.test.tsx +++ b/src/components/Inspector.test.tsx @@ -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', diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index 9b0de7cf..8f89a6d9 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -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 onDeleteProperty?: (path: string, key: string) => Promise onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise + onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise onCreateAndOpenNote?: (title: string) => Promise onInitializeProperties?: (path: string) => void onToggleRawEditor?: () => void } +function buildTypeEntryMap(entries: VaultEntry[]): Record { + const map: Record = {} + 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 + typeEntryMap: Record + vaultPath?: string + referencedBy: VaultEntry[] + onNavigate: (target: string) => void + onCreateAndOpenNote?: (title: string) => Promise + onUpdateProperty?: (key: string, value: FrontmatterValue) => void + onDeleteProperty?: (key: string) => void + onAddProperty?: (key: string, value: FrontmatterValue) => void + onCreateMissingType?: (typeName: string) => Promise +}) { + return ( + <> + + + + + + + ) +} + +function PrimaryInspectorPanel({ + entry, + frontmatterState, + frontmatter, + entries, + typeEntryMap, + vaultPath, + referencedBy, + onNavigate, + onToggleRawEditor, + onInitializeProperties, + onCreateAndOpenNote, + onUpdateProperty, + onDeleteProperty, + onAddProperty, + onCreateMissingType, +}: { + entry: VaultEntry + frontmatterState: ReturnType + frontmatter: ReturnType + entries: VaultEntry[] + typeEntryMap: Record + vaultPath?: string + referencedBy: VaultEntry[] + onNavigate: (target: string) => void + onToggleRawEditor?: () => void + onInitializeProperties?: (path: string) => void + onCreateAndOpenNote?: (title: string) => Promise + onUpdateProperty?: (key: string, value: FrontmatterValue) => void + onDeleteProperty?: (key: string) => void + onAddProperty?: (key: string, value: FrontmatterValue) => void + onCreateMissingType?: (typeName: string) => Promise +}) { + if (frontmatterState === 'valid') { + return ( + + ) + } + + if (frontmatterState === 'invalid') { + return onToggleRawEditor ? : null + } + + return onInitializeProperties ? 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 = {} - 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 (
) } diff --git a/src/components/inspector/useInspectorPropertyActions.ts b/src/components/inspector/useInspectorPropertyActions.ts new file mode 100644 index 00000000..db6e6156 --- /dev/null +++ b/src/components/inspector/useInspectorPropertyActions.ts @@ -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 + onDeleteProperty?: (path: string, key: string) => Promise + onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise + onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise +} + +function bindEntryAction( + 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) | 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>(entry, onUpdateFrontmatter), + [entry, onUpdateFrontmatter], + ) + const handleDeleteProperty = useMemo( + () => bindEntryAction<[string], Promise>(entry, onDeleteProperty), + [entry, onDeleteProperty], + ) + const handleAddProperty = useMemo( + () => bindEntryAction<[string, FrontmatterValue], Promise>(entry, onAddProperty), + [entry, onAddProperty], + ) + const handleCreateMissingType = useMemo( + () => bindMissingTypeAction(entry, onCreateMissingType), + [entry, onCreateMissingType], + ) + + return { + handleUpdateProperty, + handleDeleteProperty, + handleAddProperty, + handleCreateMissingType, + } +} diff --git a/src/components/propertyPanelLayout.test.tsx b/src/components/propertyPanelLayout.test.tsx index 7f40b6fd..cf7192d8 100644 --- a/src/components/propertyPanelLayout.test.tsx +++ b/src/components/propertyPanelLayout.test.tsx @@ -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) { + return render( + + + , + ) +} + describe('property panel shared grid layout', () => { it('uses a shared fit-content column grid with subgridded rows', () => { - render( - - ) + 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( - - ) + 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( - - ) 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( - - ) + 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( - - ) + 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( - - ) + 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( - - ) + renderPropertiesPanel({ + entry, + frontmatter: { Owner: 'Luca' }, + onUpdateProperty: vi.fn(), + }) expect(screen.getByText('Luca').parentElement).toHaveClass('px-0') }) diff --git a/src/components/propertyTypeIconControl.test.tsx b/src/components/propertyTypeIconControl.test.tsx index 1da06d00..b566fb19 100644 --- a/src/components/propertyTypeIconControl.test.tsx +++ b/src/components/propertyTypeIconControl.test.tsx @@ -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 => ({ ...overrides, }) +function render(ui: ReactElement) { + return rtlRender(ui, { wrapper: TooltipProvider }) +} + describe('property type icon control', () => { beforeEach(() => { resetVaultConfigStore() diff --git a/tests/smoke/missing-type-warning.spec.ts b/tests/smoke/missing-type-warning.spec.ts new file mode 100644 index 00000000..8ee8f3ef --- /dev/null +++ b/tests/smoke/missing-type-warning.spec.ts @@ -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) + }) +})