diff --git a/design/change-note-type.pen b/design/change-note-type.pen new file mode 100644 index 00000000..649cb778 --- /dev/null +++ b/design/change-note-type.pen @@ -0,0 +1 @@ +{"children":[{"type":"frame","id":"typePicker1","x":0,"y":0,"name":"Type Picker — Dropdown Open","clip":true,"width":280,"height":300,"fill":"#ffffff","layout":"vertical","padding":[16,16],"gap":12,"children":[{"type":"text","id":"heading1","text":"Properties Panel — Type Selector","fontSize":14,"fontWeight":600,"fill":"#1a1a1a"},{"type":"frame","id":"typeRow1","name":"Type Row","width":"fill_container","height":32,"alignItems":"center","justifyContent":"space-between","children":[{"type":"text","id":"typeLabel1","text":"TYPE","fontSize":10,"fontWeight":600,"letterSpacing":1.2,"fill":"#8b8b8b","fontFamily":"IBM Plex Mono"},{"type":"frame","id":"typeTrigger1","name":"Select Trigger","height":26,"padding":[2,8],"gap":4,"alignItems":"center","fill":"#e8f0fe","cornerRadius":6,"children":[{"type":"text","id":"typeValue1","text":"Note","fontSize":12,"fontWeight":500,"fill":"#4285f4"},{"type":"text","id":"chevron1","text":"▾","fontSize":10,"fill":"#4285f4"}]}]},{"type":"frame","id":"dropdown1","name":"Dropdown Menu (Open)","width":160,"fill":"#ffffff","cornerRadius":8,"padding":[4,4],"gap":2,"stroke":{"align":"outside","thickness":1,"fill":"#e0e0e0"},"children":[{"type":"frame","id":"optNone","name":"Option: None","width":"fill_container","height":28,"padding":[0,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"text","id":"optNoneText","text":"None","fontSize":13,"fill":"#8b8b8b"}]},{"type":"frame","id":"separator1","name":"Separator","width":"fill_container","height":1,"fill":"#e0e0e0"},{"type":"frame","id":"optProject","name":"Option: Project","width":"fill_container","height":28,"padding":[0,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"text","id":"optProjectText","text":"Project","fontSize":13,"fill":"#1a1a1a"}]},{"type":"frame","id":"optPerson","name":"Option: Person","width":"fill_container","height":28,"padding":[0,8],"alignItems":"center","cornerRadius":4,"fill":"#f0f0f0","children":[{"type":"text","id":"optPersonText","text":"Person","fontSize":13,"fill":"#1a1a1a"}]},{"type":"frame","id":"optTopic","name":"Option: Topic","width":"fill_container","height":28,"padding":[0,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"text","id":"optTopicText","text":"Topic","fontSize":13,"fill":"#1a1a1a"}]},{"type":"frame","id":"optExperiment","name":"Option: Experiment","width":"fill_container","height":28,"padding":[0,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"text","id":"optExpText","text":"Experiment","fontSize":13,"fill":"#1a1a1a"}]}]}]}],"variables":{}} \ No newline at end of file diff --git a/src/components/DynamicPropertiesPanel.test.tsx b/src/components/DynamicPropertiesPanel.test.tsx index 284d8e2b..8adbd225 100644 --- a/src/components/DynamicPropertiesPanel.test.tsx +++ b/src/components/DynamicPropertiesPanel.test.tsx @@ -1,8 +1,17 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { DynamicPropertiesPanel, containsWikilinks } from './DynamicPropertiesPanel' import type { VaultEntry } from '../types' +// Radix Select needs ResizeObserver and pointer/scroll APIs in JSDOM +beforeAll(() => { + global.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } + Element.prototype.scrollIntoView = vi.fn() + Element.prototype.hasPointerCapture = () => false + Element.prototype.setPointerCapture = vi.fn() + Element.prototype.releasePointerCapture = vi.fn() +}) + const makeEntry = (overrides: Partial = {}): VaultEntry => ({ path: '/vault/note/test.md', filename: 'test.md', @@ -218,7 +227,7 @@ describe('DynamicPropertiesPanel', () => { expect(onAddProperty).toHaveBeenCalledWith('priority', 'high') }) - it('handles navigating to type via click', () => { + it('handles navigating to type via click in read-only mode', () => { render( { expect(onNavigate).toHaveBeenCalledWith('type/project') }) + describe('TypeSelector', () => { + const typeEntries = [ + makeEntry({ path: '/vault/type/project.md', title: 'Project', isA: 'Type' }), + makeEntry({ path: '/vault/type/person.md', title: 'Person', isA: 'Type' }), + makeEntry({ path: '/vault/type/topic.md', title: 'Topic', isA: 'Type' }), + ] + + it('renders as dropdown when editable', () => { + render( + + ) + expect(screen.getByTestId('type-selector')).toBeInTheDocument() + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + it('shows available types in dropdown', () => { + render( + + ) + fireEvent.pointerDown(screen.getByRole('combobox'), { button: 0, pointerType: 'mouse' }) + expect(screen.getByRole('option', { name: 'None' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Person' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Project' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Topic' })).toBeInTheDocument() + }) + + function openAndSelect(optionName: string) { + fireEvent.pointerDown(screen.getByRole('combobox'), { button: 0, pointerType: 'mouse' }) + fireEvent.click(screen.getByRole('option', { name: optionName })) + } + + it('calls onUpdateProperty when type selected', () => { + render( + + ) + openAndSelect('Project') + expect(onUpdateProperty).toHaveBeenCalledWith('type', 'Project') + }) + + it('clears type when None selected', () => { + render( + + ) + openAndSelect('None') + expect(onUpdateProperty).toHaveBeenCalledWith('type', null) + }) + + it('shows current type even when not in available types', () => { + render( + + ) + fireEvent.pointerDown(screen.getByRole('combobox'), { button: 0, pointerType: 'mouse' }) + expect(screen.getByRole('option', { name: 'CustomType' })).toBeInTheDocument() + }) + + it('shows None placeholder when entry has no type', () => { + render( + + ) + expect(screen.getByTestId('type-selector')).toBeInTheDocument() + expect(screen.getByText('None')).toBeInTheDocument() + }) + }) + it('renders modified date', () => { render( void }) { +const TYPE_NONE = '__none__' + +function ReadOnlyType({ isA, onNavigate }: { isA?: string | null; onNavigate?: (target: string) => void }) { if (!isA) return null return (
@@ -162,6 +165,47 @@ function TypeRow({ isA, onNavigate }: { isA?: string | null; onNavigate?: (targe ) } +function TypeSelector({ isA, availableTypes, onUpdateProperty, onNavigate }: { + isA?: string | null; availableTypes: string[] + onUpdateProperty?: (key: string, value: FrontmatterValue) => void + onNavigate?: (target: string) => void +}) { + if (!onUpdateProperty) return + + const currentValue = isA || TYPE_NONE + const options = isA && !availableTypes.includes(isA) + ? [...availableTypes, isA].sort((a, b) => a.localeCompare(b)) + : availableTypes + + return ( +
+ Type + +
+ ) +} + function PropertyRow({ propKey, value, editingKey, onStartEdit, onSave, onSaveList, onUpdate, onDelete }: { propKey: string; value: FrontmatterValue; editingKey: string | null onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void @@ -219,6 +263,7 @@ export function DynamicPropertiesPanel({ entry, content, frontmatter, + entries, onUpdateProperty, onDeleteProperty, onAddProperty, @@ -227,6 +272,7 @@ export function DynamicPropertiesPanel({ entry: VaultEntry content: string | null frontmatter: ParsedFrontmatter + entries?: VaultEntry[] onUpdateProperty?: (key: string, value: FrontmatterValue) => void onDeleteProperty?: (key: string) => void onAddProperty?: (key: string, value: FrontmatterValue) => void @@ -237,6 +283,13 @@ export function DynamicPropertiesPanel({ const wordCount = countWords(content ?? '') + const availableTypes = useMemo(() => + (entries ?? []) + .filter(e => e.isA === 'Type') + .map(e => e.title) + .sort((a, b) => a.localeCompare(b)) + , [entries]) + const propertyEntries = useMemo(() => { return Object.entries(frontmatter) .filter(([key, value]) => !SKIP_KEYS.has(key) && !RELATIONSHIP_KEYS.has(key) && !containsWikilinks(value)) @@ -264,7 +317,7 @@ export function DynamicPropertiesPanel({
{/* Editable properties section */}
- + {propertyEntries.map(([key, value]) => ( ))} diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index 16aa95f0..43d85c75 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -141,6 +141,7 @@ export function Inspector({ <>