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) <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,10 @@ pub struct VaultEntry {
|
||||
/// Only includes strings, numbers, and booleans — arrays/objects are excluded.
|
||||
#[serde(default)]
|
||||
pub properties: HashMap<String, serde_json::Value>,
|
||||
/// 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<String>,
|
||||
/// File kind: "markdown", "text", or "binary".
|
||||
/// Determines how the frontend renders and opens the file.
|
||||
#[serde(rename = "fileKind", default = "default_file_kind")]
|
||||
|
||||
@@ -55,6 +55,8 @@ pub(crate) struct Frontmatter {
|
||||
pub favorite: Option<bool>,
|
||||
#[serde(rename = "_favorite_index", default)]
|
||||
pub favorite_index: Option<i64>,
|
||||
#[serde(rename = "_list_properties_display", default)]
|
||||
pub list_properties_display: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Custom deserializer for boolean fields that may arrive as strings.
|
||||
@@ -169,6 +171,7 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
"status",
|
||||
"_favorite",
|
||||
"_favorite_index",
|
||||
"_list_properties_display",
|
||||
];
|
||||
let filtered: serde_json::Map<String, serde_json::Value> = 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.
|
||||
|
||||
@@ -117,6 +117,9 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
|
||||
visible: frontmatter.visible,
|
||||
favorite: frontmatter.favorite.unwrap_or(false),
|
||||
favorite_index: frontmatter.favorite_index,
|
||||
list_properties_display: frontmatter
|
||||
.list_properties_display
|
||||
.unwrap_or_default(),
|
||||
word_count,
|
||||
outgoing_links,
|
||||
properties,
|
||||
|
||||
@@ -1457,6 +1457,44 @@ fn test_scan_vault_folders_flat_vault() {
|
||||
assert!(folders.is_empty(), "flat vault has no visible folders");
|
||||
}
|
||||
|
||||
// --- list_properties_display tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_list_properties_display() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\n_list_properties_display:\n - rating\n - genre\n---\n# Movies\n";
|
||||
let entry = parse_test_entry(&dir, "movies.md", content);
|
||||
assert_eq!(entry.list_properties_display, vec!["rating", "genre"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_list_properties_display_absent_defaults_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\n---\n# Books\n";
|
||||
let entry = parse_test_entry(&dir, "books.md", content);
|
||||
assert!(entry.list_properties_display.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_properties_display_not_in_properties_or_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content =
|
||||
"---\ntype: Type\n_list_properties_display:\n - rating\n---\n# Movies\n";
|
||||
let entry = parse_test_entry(&dir, "movies.md", content);
|
||||
assert!(
|
||||
!entry
|
||||
.properties
|
||||
.contains_key("_list_properties_display"),
|
||||
"_list_properties_display must not leak into properties map"
|
||||
);
|
||||
assert!(
|
||||
!entry
|
||||
.relationships
|
||||
.contains_key("_list_properties_display"),
|
||||
"_list_properties_display must not leak into relationships map"
|
||||
);
|
||||
}
|
||||
|
||||
// Frontmatter update/delete tests are in frontmatter.rs
|
||||
// save_image tests are in vault/image.rs
|
||||
// purge_trash tests are in vault/trash.rs
|
||||
|
||||
118
src/components/NoteItem.test.tsx
Normal file
118
src/components/NoteItem.test.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { NoteItem } from './NoteItem'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
vi.mock('../mock-tauri', () => ({ isTauri: () => false, mockInvoke: vi.fn() }))
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): 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> = {}): 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(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
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(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
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(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
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(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
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(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
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(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('property-chips')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
|
||||
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 (
|
||||
<div className="mt-1 flex flex-wrap gap-1" data-testid="property-chips">
|
||||
{chips.map(({ key, values }) =>
|
||||
values.map((v, i) => (
|
||||
<span
|
||||
key={`${key}-${i}`}
|
||||
className="inline-block max-w-full truncate rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{v}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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}
|
||||
</div>
|
||||
)}
|
||||
{!isBinary && te?.listPropertiesDisplay && te.listPropertiesDisplay.length > 0 && (
|
||||
<PropertyChips entry={entry} displayProps={te.listPropertiesDisplay} />
|
||||
)}
|
||||
{!isBinary && (entry.trashed && entry.trashedAt
|
||||
? <TrashDateLine entry={entry} />
|
||||
: <div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
|
||||
|
||||
@@ -100,7 +100,7 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
|
||||
return (
|
||||
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
||||
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={handleCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} />
|
||||
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} isSectionGroup={isSectionGroup} entries={entries} onSortChange={handleSortChange} onCreateNote={handleCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} onUpdateTypeProperty={onUpdateTypeSort} />
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
|
||||
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
|
||||
{entitySelection ? (
|
||||
|
||||
142
src/components/note-list/ListPropertiesPopover.tsx
Normal file
142
src/components/note-list/ListPropertiesPopover.tsx
Normal file
@@ -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<string>()
|
||||
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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="flex items-center gap-2 rounded px-1 py-1 hover:bg-muted"
|
||||
data-testid={`list-prop-item-${id}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 cursor-grab items-center text-muted-foreground active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<DotsSixVertical size={14} />
|
||||
</button>
|
||||
<label className="flex flex-1 cursor-pointer items-center gap-2 text-[13px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(id)}
|
||||
className="accent-primary"
|
||||
style={{ width: 14, height: 14 }}
|
||||
/>
|
||||
<span className="truncate">{id}</span>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex items-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Configure list properties"
|
||||
data-testid="list-properties-btn"
|
||||
>
|
||||
<SlidersHorizontal size={16} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-56 p-2" data-testid="list-properties-popover">
|
||||
<div className="mb-2 px-1 text-[11px] font-medium text-muted-foreground">
|
||||
Show in note list
|
||||
</div>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={orderedItems} strategy={verticalListSortingStrategy}>
|
||||
{orderedItems.map((key) => (
|
||||
<SortablePropertyItem
|
||||
key={key}
|
||||
id={key}
|
||||
checked={selectedSet.has(key)}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={onToggleSearch} title="Search notes">
|
||||
<MagnifyingGlass size={16} />
|
||||
</button>
|
||||
{isSectionGroup && typeDocument && entries && onUpdateTypeProperty && (
|
||||
<ListPropertiesPopover typeDocument={typeDocument} entries={entries} onSave={onUpdateTypeProperty} />
|
||||
)}
|
||||
{isTrashView && trashCount > 0 && (
|
||||
<button
|
||||
className="flex items-center text-destructive transition-colors hover:text-destructive/80"
|
||||
|
||||
@@ -15,6 +15,7 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
||||
order: { order: null },
|
||||
template: { template: null }, sort: { sort: null }, visible: { visible: null },
|
||||
_favorite: { favorite: false }, _favorite_index: { favoriteIndex: null },
|
||||
_list_properties_display: { listPropertiesDisplay: [] },
|
||||
}
|
||||
|
||||
/** Check if a string contains a wikilink pattern `[[...]]`. */
|
||||
@@ -64,6 +65,7 @@ export function frontmatterToEntryPatch(
|
||||
visible: { visible: value === false ? false : null },
|
||||
_favorite: { favorite: Boolean(value) },
|
||||
_favorite_index: { favoriteIndex: typeof value === 'number' ? value : null },
|
||||
_list_properties_display: { listPropertiesDisplay: Array.isArray(value) ? value.map(String) : [] },
|
||||
}
|
||||
// Also update the relationships map for wikilink-containing values
|
||||
const wikilinks = value != null ? extractWikilinks(value) : []
|
||||
|
||||
@@ -401,6 +401,16 @@ describe('frontmatterToEntryPatch', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('maps _list_properties_display update to listPropertiesDisplay array', () => {
|
||||
const result = frontmatterToEntryPatch('update', '_list_properties_display', ['rating', 'genre'])
|
||||
expect(result.patch).toEqual({ listPropertiesDisplay: ['rating', 'genre'] })
|
||||
})
|
||||
|
||||
it('maps _list_properties_display delete to empty array', () => {
|
||||
const result = frontmatterToEntryPatch('delete', '_list_properties_display')
|
||||
expect(result.patch).toEqual({ listPropertiesDisplay: [] })
|
||||
})
|
||||
|
||||
it('returns empty patch for unknown key on delete, with relationship removal', () => {
|
||||
const result = frontmatterToEntryPatch('delete', 'unknown_key')
|
||||
expect(result.patch).toEqual({})
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface VaultEntry {
|
||||
favorite: boolean
|
||||
/** Display order within the FAVORITES section (lower = higher). */
|
||||
favoriteIndex: number | null
|
||||
/** Properties to display as chips in the note list for this Type's notes. */
|
||||
listPropertiesDisplay: string[]
|
||||
/** All wikilink targets found in the note content. Extracted from [[target]] patterns. */
|
||||
outgoingLinks: string[]
|
||||
/** Custom scalar frontmatter properties (non-relationship, non-structural). */
|
||||
|
||||
Reference in New Issue
Block a user