From 8d90b8489bc6302c65d2c0a8fc9ec671718d31bc Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 4 Apr 2026 12:52:54 +0200 Subject: [PATCH] feat: add type-specific property chips in note list Parse _list_properties_display from type file frontmatter and render property/relationship values as chips below note snippets. Add a SlidersHorizontal config popover in the note list header (sectionGroup views only) with checkboxes and drag-to-reorder via dnd-kit. Changes are saved back to the type file's frontmatter immediately. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/vault/entry.rs | 4 + src-tauri/src/vault/frontmatter.rs | 4 + src-tauri/src/vault/mod.rs | 3 + src-tauri/src/vault/mod_tests.rs | 38 +++++ src/components/NoteItem.test.tsx | 118 +++++++++++++++ src/components/NoteItem.tsx | 57 +++++++ src/components/NoteList.tsx | 2 +- .../note-list/ListPropertiesPopover.tsx | 142 ++++++++++++++++++ src/components/note-list/NoteListHeader.tsx | 9 +- src/hooks/frontmatterOps.ts | 2 + src/hooks/useNoteActions.test.ts | 10 ++ src/types.ts | 2 + 12 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 src/components/NoteItem.test.tsx create mode 100644 src/components/note-list/ListPropertiesPopover.tsx diff --git a/src-tauri/src/vault/entry.rs b/src-tauri/src/vault/entry.rs index fdbbff5c..e464dbe8 100644 --- a/src-tauri/src/vault/entry.rs +++ b/src-tauri/src/vault/entry.rs @@ -75,6 +75,10 @@ pub struct VaultEntry { /// Only includes strings, numbers, and booleans — arrays/objects are excluded. #[serde(default)] pub properties: HashMap, + /// Properties to display as chips in the note list for this Type's notes. + /// Configured via `_list_properties_display` in the type file's frontmatter. + #[serde(rename = "listPropertiesDisplay", default)] + pub list_properties_display: Vec, /// File kind: "markdown", "text", or "binary". /// Determines how the frontend renders and opens the file. #[serde(rename = "fileKind", default = "default_file_kind")] diff --git a/src-tauri/src/vault/frontmatter.rs b/src-tauri/src/vault/frontmatter.rs index c1ac62fa..3dcb8044 100644 --- a/src-tauri/src/vault/frontmatter.rs +++ b/src-tauri/src/vault/frontmatter.rs @@ -55,6 +55,8 @@ pub(crate) struct Frontmatter { pub favorite: Option, #[serde(rename = "_favorite_index", default)] pub favorite_index: Option, + #[serde(rename = "_list_properties_display", default)] + pub list_properties_display: Option>, } /// Custom deserializer for boolean fields that may arrive as strings. @@ -169,6 +171,7 @@ fn parse_frontmatter(data: &HashMap) -> Frontmatter { "status", "_favorite", "_favorite_index", + "_list_properties_display", ]; let filtered: serde_json::Map = data .iter() @@ -205,6 +208,7 @@ const SKIP_KEYS: &[&str] = &[ "status", "_favorite", "_favorite_index", + "_list_properties_display", ]; /// Extract all wikilink-containing fields from raw YAML frontmatter. diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index bbac8bab..54c4a289 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -117,6 +117,9 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result ({ isTauri: () => false, mockInvoke: vi.fn() })) + +function makeEntry(overrides: Partial = {}): VaultEntry { + return { + path: '/vault/test.md', filename: 'test.md', title: 'Test Note', + isA: 'Movie', aliases: [], belongsTo: [], relatedTo: [], + status: null, archived: false, trashed: false, trashedAt: null, + modifiedAt: 1700000000, createdAt: null, fileSize: 100, + snippet: 'A snippet', wordCount: 50, + relationships: {}, icon: null, color: null, order: null, + sidebarLabel: null, template: null, sort: null, view: null, + visible: null, favorite: false, favoriteIndex: null, + outgoingLinks: [], properties: {}, + listPropertiesDisplay: [], + ...overrides, + } +} + +function makeTypeEntry(overrides: Partial = {}): VaultEntry { + return makeEntry({ + path: '/vault/movie.md', filename: 'movie.md', title: 'Movie', + isA: 'Type', listPropertiesDisplay: [], + ...overrides, + }) +} + +const noop = vi.fn() + +describe('NoteItem property chips', () => { + it('renders property chips when type has listPropertiesDisplay', () => { + const entry = makeEntry({ + properties: { rating: 4, genre: 'Drama' }, + }) + const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating', 'genre'] }) + + render( + + ) + + expect(screen.getByTestId('property-chips')).toBeInTheDocument() + expect(screen.getByText('4')).toBeInTheDocument() + expect(screen.getByText('Drama')).toBeInTheDocument() + }) + + it('does not render chips when listPropertiesDisplay is empty', () => { + const entry = makeEntry({ properties: { rating: 4 } }) + const typeEntry = makeTypeEntry({ listPropertiesDisplay: [] }) + + render( + + ) + + expect(screen.queryByTestId('property-chips')).not.toBeInTheDocument() + }) + + it('skips chips for missing properties', () => { + const entry = makeEntry({ properties: { rating: 4 } }) + const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating', 'genre'] }) + + render( + + ) + + expect(screen.getByText('4')).toBeInTheDocument() + expect(screen.queryByText('genre')).not.toBeInTheDocument() + }) + + it('renders relationship values as display labels', () => { + const entry = makeEntry({ + relationships: { Director: ['[[spielberg|Steven Spielberg]]'] }, + }) + const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['Director'] }) + + render( + + ) + + expect(screen.getByText('Steven Spielberg')).toBeInTheDocument() + }) + + it('shows hostname for URL properties', () => { + const entry = makeEntry({ + properties: { url: 'https://www.example.com/page/123' }, + }) + const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['url'] }) + + render( + + ) + + expect(screen.getByText('www.example.com')).toBeInTheDocument() + }) + + it('does not render chips for binary files', () => { + const entry = makeEntry({ + fileKind: 'binary', + properties: { rating: 4 }, + }) + const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating'] }) + + render( + + ) + + expect(screen.queryByTestId('property-chips')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index a9232fd7..57edd51a 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -10,6 +10,7 @@ import { getTypeColor, getTypeLightColor } from '../utils/typeColors' import { resolveIcon } from '../utils/iconRegistry' import { relativeDate, getDisplayDate } from '../utils/noteListHelpers' import { isEmoji } from '../utils/emoji' +import { wikilinkDisplay } from '../utils/wikilink' const TYPE_ICON_MAP: Record>> = { Project: Wrench, @@ -85,6 +86,59 @@ function StateBadge({ archived, trashed }: { archived: boolean; trashed: boolean return null } +function formatChipValue(value: unknown): string | null { + if (value === null || value === undefined || value === '') return null + const s = String(value) + // URL: show only hostname + try { + if (s.startsWith('http://') || s.startsWith('https://')) return new URL(s).hostname + } catch { /* not a URL */ } + return s.length > 40 ? s.slice(0, 37) + '…' : s +} + +function resolveChipValues(entry: VaultEntry, propName: string): string[] { + // Check relationships first (wikilink values) + const relKey = Object.keys(entry.relationships).find((k) => k.toLowerCase() === propName.toLowerCase()) + if (relKey) { + return entry.relationships[relKey].map((ref) => wikilinkDisplay(ref)).filter(Boolean) + } + // Check scalar properties + const propKey = Object.keys(entry.properties).find((k) => k.toLowerCase() === propName.toLowerCase()) + if (!propKey) return [] + const val = entry.properties[propKey] + if (Array.isArray(val)) return val.map((v) => formatChipValue(v)).filter((v): v is string => v !== null) + const formatted = formatChipValue(val) + return formatted ? [formatted] : [] +} + +function PropertyChips({ entry, displayProps }: { entry: VaultEntry; displayProps: string[] }) { + const chips = useMemo(() => { + const result: { key: string; values: string[] }[] = [] + for (const prop of displayProps) { + const values = resolveChipValues(entry, prop) + if (values.length > 0) result.push({ key: prop, values }) + } + return result + }, [entry, displayProps]) + + if (chips.length === 0) return null + + return ( +
+ {chips.map(({ key, values }) => + values.map((v, i) => ( + + {v} + + )) + )} +
+ ) +} + function noteItemStyle(isSelected: boolean, isMultiSelected: boolean, typeColor: string, typeLightColor: string): React.CSSProperties { const base: React.CSSProperties = { padding: isSelected && !isMultiSelected ? '14px 16px 14px 13px' : '14px 16px' } if (isMultiSelected) base.backgroundColor = 'color-mix(in srgb, var(--accent-blue) 10%, transparent)' @@ -153,6 +207,9 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig {entry.snippet} )} + {!isBinary && te?.listPropertiesDisplay && te.listPropertiesDisplay.length > 0 && ( + + )} {!isBinary && (entry.trashed && entry.trashedAt ? :
{relativeDate(getDisplayDate(entry))}
diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index bb9b045b..93693f32 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -100,7 +100,7 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot return (
- +
{entitySelection ? ( diff --git a/src/components/note-list/ListPropertiesPopover.tsx b/src/components/note-list/ListPropertiesPopover.tsx new file mode 100644 index 00000000..05a7304b --- /dev/null +++ b/src/components/note-list/ListPropertiesPopover.tsx @@ -0,0 +1,142 @@ +import { useState, useMemo, useCallback } from 'react' +import { SlidersHorizontal, DotsSixVertical } from '@phosphor-icons/react' +import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' +import type { VaultEntry } from '../../types' +import { + DndContext, closestCenter, KeyboardSensor, PointerSensor, + useSensor, useSensors, type DragEndEvent, +} from '@dnd-kit/core' +import { + SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, + arrayMove, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' + +interface ListPropertiesPopoverProps { + typeDocument: VaultEntry + entries: VaultEntry[] + onSave: (path: string, key: string, value: string[] | null) => void +} + +/** Collect all available property/relationship keys from notes of this type. */ +function collectAvailableProperties(entries: VaultEntry[], typeName: string): string[] { + const keys = new Set() + for (const entry of entries) { + if (entry.isA !== typeName) continue + for (const k of Object.keys(entry.properties)) keys.add(k) + for (const k of Object.keys(entry.relationships)) keys.add(k) + } + // Sort alphabetically for stable ordering + return [...keys].sort((a, b) => a.localeCompare(b)) +} + +function SortablePropertyItem({ id, checked, onToggle }: { id: string; checked: boolean; onToggle: (key: string) => void }) { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id }) + const style = { transform: CSS.Transform.toString(transform), transition } + + return ( +
+ + +
+ ) +} + +export function ListPropertiesPopover({ typeDocument, entries, onSave }: ListPropertiesPopoverProps) { + const [open, setOpen] = useState(false) + const currentDisplay = typeDocument.listPropertiesDisplay ?? [] + + const availableProperties = useMemo( + () => collectAvailableProperties(entries, typeDocument.title), + [entries, typeDocument.title], + ) + + // Merge: selected props first (in order), then unselected alphabetically + const orderedItems = useMemo(() => { + const selected = currentDisplay.filter((p) => availableProperties.includes(p)) + const unselected = availableProperties.filter((p) => !selected.includes(p)) + return [...selected, ...unselected] + }, [currentDisplay, availableProperties]) + + const selectedSet = useMemo(() => new Set(currentDisplay), [currentDisplay]) + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ) + + const handleToggle = useCallback((key: string) => { + const newSelected = selectedSet.has(key) + ? currentDisplay.filter((k) => k !== key) + : [...currentDisplay, key] + onSave(typeDocument.path, '_list_properties_display', newSelected.length > 0 ? newSelected : null) + }, [selectedSet, currentDisplay, typeDocument.path, onSave]) + + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + + // Only reorder within selected items + const selected = currentDisplay.filter((p) => availableProperties.includes(p)) + const oldIndex = selected.indexOf(String(active.id)) + const newIndex = selected.indexOf(String(over.id)) + if (oldIndex === -1 || newIndex === -1) return + + const reordered = arrayMove(selected, oldIndex, newIndex) + onSave(typeDocument.path, '_list_properties_display', reordered) + }, [currentDisplay, availableProperties, typeDocument.path, onSave]) + + if (availableProperties.length === 0) return null + + return ( + + + + + +
+ Show in note list +
+ + + {orderedItems.map((key) => ( + + ))} + + +
+
+ ) +} diff --git a/src/components/note-list/NoteListHeader.tsx b/src/components/note-list/NoteListHeader.tsx index 27c5fbcc..5faba038 100644 --- a/src/components/note-list/NoteListHeader.tsx +++ b/src/components/note-list/NoteListHeader.tsx @@ -4,8 +4,9 @@ import type { SortOption, SortDirection } from '../../utils/noteListHelpers' import { Input } from '@/components/ui/input' import { useDragRegion } from '../../hooks/useDragRegion' import { SortDropdown } from '../SortDropdown' +import { ListPropertiesPopover } from './ListPropertiesPopover' -export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView, trashCount, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onEmptyTrash }: { +export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView, trashCount, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, isSectionGroup, entries, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onEmptyTrash, onUpdateTypeProperty }: { title: string typeDocument: VaultEntry | null isEntityView: boolean @@ -17,12 +18,15 @@ export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView, sidebarCollapsed?: boolean searchVisible: boolean search: string + isSectionGroup?: boolean + entries?: VaultEntry[] onSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void onCreateNote: () => void onOpenType: (entry: VaultEntry) => void onToggleSearch: () => void onSearchChange: (value: string) => void onEmptyTrash?: () => void + onUpdateTypeProperty?: (path: string, key: string, value: string | number | boolean | string[] | null) => void }) { const { onMouseDown: onDragMouseDown } = useDragRegion() return ( @@ -41,6 +45,9 @@ export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView, + {isSectionGroup && typeDocument && entries && onUpdateTypeProperty && ( + + )} {isTrashView && trashCount > 0 && (