feat: note templates per type (💡 Note templates per tipo) (#170)
* feat: add template field to type entries and template-aware note creation - Rust: add template field to Frontmatter, VaultEntry, SKIP_KEYS - Rust: support YAML block scalar (|) for multi-line strings in frontmatter - Rust: fix value continuation detection to handle block scalars properly - TypeScript: add template to VaultEntry, buildNewEntry, frontmatterToEntryPatch - Add DEFAULT_TEMPLATES for Project, Person, Responsibility, Experiment - resolveNewNote/buildNoteContent accept optional template parameter - handleCreateNote and handleCreateNoteImmediate look up type template - Tests for all new behavior (Rust + TypeScript) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: wire template UI through Sidebar and App, add TypeCustomizePopover template section - Add template textarea with debounced save to TypeCustomizePopover - Wire handleUpdateTypeTemplate through useEntryActions → Sidebar → App - Add useDebouncedCallback hook for 500ms template save debounce - Add tests for handleUpdateTypeTemplate and TypeCustomizePopover template UI - Create design/note-templates.pen placeholder Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: useRef type arg + template field in bulk mock entries * style: rustfmt --------- Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
42
.claude-done
42
.claude-done
@@ -1,42 +0,0 @@
|
||||
# Drag & Drop Images — Implementation Summary
|
||||
|
||||
## Problem
|
||||
Dragging image files from Finder into the BlockNote editor didn't work. Tauri v2's
|
||||
`dragDropEnabled` defaults to `true`, which intercepts OS-level file drops before they
|
||||
reach the webview's HTML5 DnD API — BlockNote never received the dropped files.
|
||||
|
||||
## Solution
|
||||
Instead of disabling Tauri's drag interception, we use Tauri's `onDragDropEvent` API
|
||||
to listen for native file drops and handle them directly:
|
||||
|
||||
1. **Rust backend** (`src-tauri/src/vault/image.rs`):
|
||||
- Added `copy_image_to_vault` command that copies a file by OS path into
|
||||
`vault/attachments/` with a timestamp-prefixed filename
|
||||
- Refactored shared path logic into `prepare_attachment_path` helper
|
||||
- Validates file exists and has an image extension before copying
|
||||
|
||||
2. **Frontend** (`src/hooks/useImageDrop.ts`):
|
||||
- Added Tauri `onDragDropEvent` listener that fires on native OS drag-drop
|
||||
- On `drop`: filters for image paths, copies each to vault, calls `onImageUrl`
|
||||
- On `over`: shows drag overlay (paths aren't available until drop)
|
||||
- `copyImageToVault` invokes the new Rust command and returns an asset URL
|
||||
|
||||
3. **Editor integration** (`src/components/SingleEditorView.tsx`):
|
||||
- `useInsertImageCallback` inserts a BlockNote image block at cursor position
|
||||
- Wired into `useImageDrop` via the `onImageUrl` callback
|
||||
- `vaultPath` threaded through Editor → EditorContent → SingleEditorView
|
||||
|
||||
4. **Existing `/image` slash command** continues to work unchanged (uses `uploadFile`
|
||||
on `useCreateBlockNote` → `uploadImageFile` → `save_image` Tauri command).
|
||||
|
||||
## Commits
|
||||
- `d45f2e2` feat: add copy_image_to_vault Rust command for native drag-drop
|
||||
- `b047d0c` feat: handle Tauri native drag-drop for filesystem images
|
||||
- `2e04f5a` fix: remove nonexistent drag-drop permission from capabilities
|
||||
- `23ad982` fix: correct DragDropEvent type handling for 'over' events
|
||||
|
||||
## Tests
|
||||
- 4 new Rust tests for `copy_image_to_vault` (success, missing file, non-image, all extensions)
|
||||
- 1 new frontend test for `useImageDrop` with onImageUrl + vaultPath params
|
||||
- All 300 Rust tests pass, 1167 frontend tests pass
|
||||
- Coverage: Rust 85.47%, Frontend 78.64%
|
||||
1
.claude-pid
Normal file
1
.claude-pid
Normal file
@@ -0,0 +1 @@
|
||||
81859
|
||||
1
design/note-templates.pen
Normal file
1
design/note-templates.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
@@ -15,7 +15,7 @@ pub enum FrontmatterValue {
|
||||
|
||||
/// Characters that require a YAML string value to be quoted.
|
||||
fn has_yaml_special_chars(s: &str) -> bool {
|
||||
s.contains(':') || s.contains('#') || s.contains('\n')
|
||||
s.contains(':') || s.contains('#')
|
||||
}
|
||||
|
||||
/// Check if a string starts with a YAML collection indicator (array or map).
|
||||
@@ -41,6 +41,23 @@ fn format_list_item(item: &str) -> String {
|
||||
format!(" - {}", quote_yaml_string(item))
|
||||
}
|
||||
|
||||
/// Format a multi-line string as a YAML block scalar (`|`).
|
||||
/// Each line is indented by 2 spaces; empty lines are preserved as blank.
|
||||
fn format_block_scalar(s: &str) -> String {
|
||||
let indented = s
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", l)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("|\n{}", indented)
|
||||
}
|
||||
|
||||
/// Format a number for YAML (integers without decimal, floats with).
|
||||
fn format_yaml_number(n: f64) -> String {
|
||||
if n.fract() == 0.0 {
|
||||
@@ -54,7 +71,9 @@ impl FrontmatterValue {
|
||||
pub fn to_yaml_value(&self) -> String {
|
||||
match self {
|
||||
FrontmatterValue::String(s) => {
|
||||
if needs_yaml_quoting(s) {
|
||||
if s.contains('\n') {
|
||||
format_block_scalar(s)
|
||||
} else if needs_yaml_quoting(s) {
|
||||
quote_yaml_string(s)
|
||||
} else {
|
||||
s.clone()
|
||||
@@ -113,16 +132,20 @@ fn line_is_key(line: &str, key: &str) -> bool {
|
||||
fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec<String> {
|
||||
let yaml_key = format_yaml_key(key);
|
||||
let yaml_value = value.to_yaml_value();
|
||||
if yaml_value.contains('\n') {
|
||||
if yaml_value.starts_with("|\n") {
|
||||
// Block scalar: key and indicator on the same line, content follows
|
||||
vec![format!("{}: {}", yaml_key, yaml_value)]
|
||||
} else if yaml_value.contains('\n') {
|
||||
vec![format!("{}:", yaml_key), yaml_value]
|
||||
} else {
|
||||
vec![format!("{}: {}", yaml_key, yaml_value)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a line is a YAML list continuation (` - ...`) rather than a new key.
|
||||
fn is_list_continuation(line: &str) -> bool {
|
||||
line.starts_with(" - ") || line.starts_with(" -\t")
|
||||
/// Check if a line continues the previous key's value (indented list item,
|
||||
/// block scalar content, or blank line inside a block scalar).
|
||||
fn is_value_continuation(line: &str) -> bool {
|
||||
line.is_empty() || line.starts_with(" ") || line.starts_with('\t')
|
||||
}
|
||||
|
||||
/// Split content into frontmatter body and the rest after the closing `---`.
|
||||
@@ -163,8 +186,8 @@ fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue
|
||||
|
||||
found_key = true;
|
||||
i += 1;
|
||||
// Skip list continuation lines belonging to this key
|
||||
while i < lines.len() && is_list_continuation(lines[i]) {
|
||||
// Skip continuation lines belonging to this key (lists, block scalars)
|
||||
while i < lines.len() && is_value_continuation(lines[i]) {
|
||||
i += 1;
|
||||
}
|
||||
// Insert replacement value (if any)
|
||||
@@ -699,6 +722,94 @@ mod tests {
|
||||
assert!(updated.contains("title: New Title"));
|
||||
}
|
||||
|
||||
// --- block scalar (multi-line string) tests ---
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_multiline_uses_block_scalar() {
|
||||
let v = FrontmatterValue::String("line 1\nline 2\nline 3".to_string());
|
||||
let yaml = v.to_yaml_value();
|
||||
assert!(yaml.starts_with("|\n"));
|
||||
assert!(yaml.contains(" line 1"));
|
||||
assert!(yaml.contains(" line 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_field_block_scalar() {
|
||||
let v = FrontmatterValue::String("## Objective\n\n## Timeline".to_string());
|
||||
let lines = format_yaml_field("template", &v);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(lines[0].starts_with("template: |\n"));
|
||||
assert!(lines[0].contains(" ## Objective"));
|
||||
assert!(lines[0].contains(" ## Timeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_add() {
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let template = "## Objective\n\n## Timeline";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("template: |"));
|
||||
assert!(updated.contains(" ## Objective"));
|
||||
assert!(updated.contains(" ## Timeline"));
|
||||
assert!(updated.contains("type: Type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_replace() {
|
||||
let content = "---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n";
|
||||
let new_template = "## New\n\n## Content";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(new_template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains(" ## New"));
|
||||
assert!(updated.contains(" ## Content"));
|
||||
assert!(!updated.contains("## Old"));
|
||||
assert!(!updated.contains("## Stuff"));
|
||||
assert!(updated.contains("color: green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_block_scalar() {
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: |\n ## Heading\n \n ## Body\ncolor: green\n---\n# Project\n";
|
||||
let updated = update_frontmatter_content(content, "template", None).unwrap();
|
||||
assert!(!updated.contains("template"));
|
||||
assert!(!updated.contains("## Heading"));
|
||||
assert!(updated.contains("color: green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_block_scalar() {
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let template = "## Objective\n\nDescribe the goal.\n\n## Timeline\n\nKey dates.";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
// Parse back with gray_matter
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
let roundtripped = map.get("template").unwrap().as_string().unwrap();
|
||||
assert!(roundtripped.contains("## Objective"));
|
||||
assert!(roundtripped.contains("## Timeline"));
|
||||
assert!(roundtripped.contains("Describe the goal."));
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_no_body_after_closing() {
|
||||
// Frontmatter with title, no body after closing ---
|
||||
|
||||
@@ -65,6 +65,9 @@ pub struct VaultEntry {
|
||||
/// Custom sidebar section label for Type entries, overriding auto-pluralization.
|
||||
#[serde(rename = "sidebarLabel")]
|
||||
pub sidebar_label: Option<String>,
|
||||
/// Markdown template for notes of this Type. When a new note is created
|
||||
/// with this type, the template body is pre-filled after the frontmatter.
|
||||
pub template: Option<String>,
|
||||
/// Word count of the note body (excludes frontmatter and H1 title).
|
||||
#[serde(rename = "wordCount")]
|
||||
pub word_count: u32,
|
||||
@@ -109,6 +112,8 @@ struct Frontmatter {
|
||||
order: Option<i64>,
|
||||
#[serde(rename = "sidebar label", default)]
|
||||
sidebar_label: Option<String>,
|
||||
#[serde(default)]
|
||||
template: Option<String>,
|
||||
}
|
||||
|
||||
/// Handles YAML fields that can be either a single string or a list of strings.
|
||||
@@ -153,6 +158,7 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"color",
|
||||
"order",
|
||||
"sidebar label",
|
||||
"template",
|
||||
];
|
||||
|
||||
/// Extract all wikilink-containing fields from raw YAML frontmatter.
|
||||
@@ -332,6 +338,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
color: frontmatter.color,
|
||||
order: frontmatter.order,
|
||||
sidebar_label: frontmatter.sidebar_label,
|
||||
template: frontmatter.template,
|
||||
word_count,
|
||||
outgoing_links,
|
||||
})
|
||||
@@ -1088,6 +1095,45 @@ References:
|
||||
assert!(entry.relationships.get("sidebar label").is_none());
|
||||
}
|
||||
|
||||
// --- template field tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_from_type_entry() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: \"## Objective\\n\\n## Timeline\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert!(entry.template.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_block_scalar() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: |\n ## Objective\n \n ## Timeline\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert!(entry.template.is_some());
|
||||
let tmpl = entry.template.unwrap();
|
||||
assert!(tmpl.contains("## Objective"));
|
||||
assert!(tmpl.contains("## Timeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_missing_defaults_to_none() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\n---\n# Note\n";
|
||||
let entry = parse_test_entry(&dir, "type/note.md", content);
|
||||
assert_eq!(entry.template, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\ntemplate: \"## Heading\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert!(entry.relationships.get("template").is_none());
|
||||
}
|
||||
|
||||
// Frontmatter update/delete tests are in frontmatter.rs
|
||||
// save_image tests are in vault/image.rs
|
||||
// purge_trash tests are in vault/trash.rs
|
||||
|
||||
@@ -34,6 +34,7 @@ const mockEntries = [
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -50,6 +51,7 @@ const mockEntries = [
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -332,7 +332,7 @@ function App() {
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onReorderSections={entryActions.handleReorderSections} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} />
|
||||
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
|
||||
@@ -51,6 +51,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -75,6 +75,7 @@ const mockEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const mockEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -70,6 +71,7 @@ const referrerEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: ['Test Project'],
|
||||
}
|
||||
|
||||
@@ -374,6 +376,7 @@ This is a test note with some words to count.
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -400,6 +403,7 @@ This is a test note with some words to count.
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -426,6 +430,7 @@ This is a test note with some words to count.
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -452,6 +457,7 @@ This is a test note with some words to count.
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -594,6 +600,7 @@ Status: Active
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
// Body text also links to grow-newsletter
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -34,6 +34,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -62,6 +63,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -87,6 +89,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -112,6 +115,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -137,6 +141,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
@@ -357,6 +362,7 @@ describe('getSortComparator', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -469,6 +475,7 @@ describe('NoteList sort controls', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -663,6 +670,7 @@ const trashedEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -689,6 +697,7 @@ const expiredTrashedEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -844,6 +853,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -1145,6 +1155,7 @@ const typeEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -38,6 +38,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: ['topic/ai', 'topic/api-design', 'person/luca'],
|
||||
},
|
||||
{
|
||||
@@ -63,6 +64,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: ['person/bob'],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -40,6 +40,7 @@ const mockEntries: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -66,6 +67,7 @@ const mockEntries: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -92,6 +94,7 @@ const mockEntries: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -118,6 +121,7 @@ const mockEntries: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -144,6 +148,7 @@ const mockEntries: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -170,6 +175,7 @@ const mockEntries: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -196,6 +202,7 @@ const mockEntries: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -222,6 +229,7 @@ const mockEntries: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
@@ -441,6 +449,7 @@ describe('Sidebar', () => {
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -467,6 +476,7 @@ describe('Sidebar', () => {
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -492,6 +502,7 @@ describe('Sidebar', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -517,6 +528,7 @@ describe('Sidebar', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
@@ -602,6 +614,7 @@ describe('Sidebar', () => {
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
render(<Sidebar entries={[...mockEntries, projectTypeEntry]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
@@ -32,6 +32,7 @@ interface SidebarProps {
|
||||
onCreateType?: (type: string) => void
|
||||
onCreateNewType?: () => void
|
||||
onCustomizeType?: (typeName: string, icon: string, color: string) => void
|
||||
onUpdateTypeTemplate?: (typeName: string, template: string) => void
|
||||
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
|
||||
modifiedCount?: number
|
||||
onCommitPush?: () => void
|
||||
@@ -229,10 +230,11 @@ function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize }: {
|
||||
)
|
||||
}
|
||||
|
||||
function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose }: {
|
||||
function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChangeTemplate, onClose }: {
|
||||
target: string | null; typeEntryMap: Record<string, VaultEntry>
|
||||
innerRef: React.Ref<HTMLDivElement>
|
||||
onCustomize: (prop: 'icon' | 'color', value: string) => void
|
||||
onChangeTemplate: (template: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
if (!target) return null
|
||||
@@ -241,8 +243,10 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose
|
||||
<TypeCustomizePopover
|
||||
currentIcon={typeEntryMap[target]?.icon ?? null}
|
||||
currentColor={typeEntryMap[target]?.color ?? null}
|
||||
currentTemplate={typeEntryMap[target]?.template ?? null}
|
||||
onChangeIcon={(icon) => onCustomize('icon', icon)}
|
||||
onChangeColor={(color) => onCustomize('color', color)}
|
||||
onChangeTemplate={onChangeTemplate}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
@@ -253,7 +257,7 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose
|
||||
|
||||
export const Sidebar = memo(function Sidebar({
|
||||
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onCustomizeType, onReorderSections, modifiedCount = 0, onCommitPush, onCollapse,
|
||||
onCustomizeType, onUpdateTypeTemplate, onReorderSections, modifiedCount = 0, onCommitPush, onCollapse,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
|
||||
@@ -302,6 +306,10 @@ export const Sidebar = memo(function Sidebar({
|
||||
applyCustomization(customizeTarget, typeEntryMap, onCustomizeType, prop, value)
|
||||
}, [customizeTarget, typeEntryMap, onCustomizeType])
|
||||
|
||||
const handleChangeTemplate = useCallback((template: string) => {
|
||||
if (customizeTarget) onUpdateTypeTemplate?.(customizeTarget, template)
|
||||
}, [customizeTarget, onUpdateTypeTemplate])
|
||||
|
||||
const sectionProps = {
|
||||
entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onContextMenu: handleContextMenu, onToggle: toggleSection,
|
||||
@@ -346,7 +354,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
|
||||
<CommitButton modifiedCount={modifiedCount} onClick={onCommitPush} />
|
||||
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} />
|
||||
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onClose={closeCustomizeTarget} />
|
||||
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onChangeTemplate={handleChangeTemplate} onClose={closeCustomizeTarget} />
|
||||
</aside>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ function makeEntry(path: string, title: string): VaultEntry {
|
||||
status: null, owner: null, cadence: null, archived: false,
|
||||
trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, outgoingLinks: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,50 +43,42 @@ describe('ICON_OPTIONS', () => {
|
||||
describe('TypeCustomizePopover', () => {
|
||||
const onChangeIcon = vi.fn()
|
||||
const onChangeColor = vi.fn()
|
||||
const onChangeTemplate = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
const renderPopover = (overrides: Partial<Parameters<typeof TypeCustomizePopover>[0]> = {}) =>
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
currentTemplate={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onChangeTemplate={onChangeTemplate}
|
||||
onClose={onClose}
|
||||
{...overrides}
|
||||
/>
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders color section and icon section', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon="wrench"
|
||||
currentColor="blue"
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
it('renders color, icon, and template sections', () => {
|
||||
renderPopover()
|
||||
expect(screen.getByText('COLOR')).toBeInTheDocument()
|
||||
expect(screen.getByText('ICON')).toBeInTheDocument()
|
||||
expect(screen.getByText('TEMPLATE')).toBeInTheDocument()
|
||||
expect(screen.getByText('Done')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders search input', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
expect(screen.getByPlaceholderText('Search icons…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters icons by search query', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search icons…')
|
||||
fireEvent.change(searchInput, { target: { value: 'book' } })
|
||||
@@ -99,15 +91,7 @@ describe('TypeCustomizePopover', () => {
|
||||
})
|
||||
|
||||
it('shows empty state when no icons match search', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search icons…')
|
||||
fireEvent.change(searchInput, { target: { value: 'zzzznonexistent' } })
|
||||
@@ -116,15 +100,7 @@ describe('TypeCustomizePopover', () => {
|
||||
})
|
||||
|
||||
it('calls onChangeColor when a color is clicked', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
|
||||
const colorButtons = screen.getAllByTitle(/red|blue|green|purple|yellow|orange|teal|pink/i)
|
||||
fireEvent.click(colorButtons[0])
|
||||
@@ -133,47 +109,70 @@ describe('TypeCustomizePopover', () => {
|
||||
})
|
||||
|
||||
it('calls onChangeIcon when an icon is clicked', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
|
||||
fireEvent.click(screen.getByTitle('wrench'))
|
||||
expect(onChangeIcon).toHaveBeenCalledWith('wrench')
|
||||
})
|
||||
|
||||
it('calls onClose when Done is clicked', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
|
||||
fireEvent.click(screen.getByText('Done'))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders all color options including teal and pink', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
|
||||
expect(screen.getByTitle('Teal')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('Pink')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// --- Template tests ---
|
||||
|
||||
it('renders template textarea', () => {
|
||||
renderPopover()
|
||||
expect(screen.getByTestId('template-textarea')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows placeholder when template is empty', () => {
|
||||
renderPopover()
|
||||
expect(screen.getByPlaceholderText('Markdown template for new notes of this type…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays current template value', () => {
|
||||
renderPopover({ currentTemplate: '## Objective\n\n## Notes' })
|
||||
const textarea = screen.getByTestId('template-textarea') as HTMLTextAreaElement
|
||||
expect(textarea.value).toBe('## Objective\n\n## Notes')
|
||||
})
|
||||
|
||||
it('updates template text on user input', () => {
|
||||
renderPopover()
|
||||
const textarea = screen.getByTestId('template-textarea')
|
||||
fireEvent.change(textarea, { target: { value: '## New Template' } })
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe('## New Template')
|
||||
})
|
||||
|
||||
it('calls onChangeTemplate after debounce', async () => {
|
||||
vi.useFakeTimers()
|
||||
renderPopover()
|
||||
const textarea = screen.getByTestId('template-textarea')
|
||||
fireEvent.change(textarea, { target: { value: '## Debounced' } })
|
||||
|
||||
// Should not be called immediately
|
||||
expect(onChangeTemplate).not.toHaveBeenCalled()
|
||||
|
||||
// Fast-forward past debounce
|
||||
vi.advanceTimersByTime(600)
|
||||
expect(onChangeTemplate).toHaveBeenCalledWith('## Debounced')
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('treats null template as empty string', () => {
|
||||
renderPopover({ currentTemplate: null })
|
||||
const textarea = screen.getByTestId('template-textarea') as HTMLTextAreaElement
|
||||
expect(textarea.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
|
||||
import { MagnifyingGlass } from '@phosphor-icons/react'
|
||||
import { ICON_OPTIONS, type IconEntry } from '../utils/iconRegistry'
|
||||
import { ACCENT_COLORS } from '../utils/typeColors'
|
||||
@@ -13,21 +13,40 @@ function filterIcons(icons: IconEntry[], query: string): IconEntry[] {
|
||||
interface TypeCustomizePopoverProps {
|
||||
currentIcon: string | null
|
||||
currentColor: string | null
|
||||
currentTemplate: string | null
|
||||
onChangeIcon: (icon: string) => void
|
||||
onChangeColor: (color: string) => void
|
||||
onChangeTemplate: (template: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Debounce a callback by `delay` ms. Returns a stable ref-based wrapper. */
|
||||
function useDebouncedCallback(fn: (v: string) => void, delay: number): (v: string) => void {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const fnRef = useRef(fn)
|
||||
useEffect(() => { fnRef.current = fn })
|
||||
|
||||
useEffect(() => () => { clearTimeout(timerRef.current) }, [])
|
||||
|
||||
return useCallback((v: string) => {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => fnRef.current(v), delay)
|
||||
}, [delay])
|
||||
}
|
||||
|
||||
export function TypeCustomizePopover({
|
||||
currentIcon,
|
||||
currentColor,
|
||||
currentTemplate,
|
||||
onChangeIcon,
|
||||
onChangeColor,
|
||||
onChangeTemplate,
|
||||
onClose,
|
||||
}: TypeCustomizePopoverProps) {
|
||||
const [selectedColor, setSelectedColor] = useState(currentColor)
|
||||
const [selectedIcon, setSelectedIcon] = useState(currentIcon)
|
||||
const [search, setSearch] = useState('')
|
||||
const [templateText, setTemplateText] = useState(currentTemplate ?? '')
|
||||
|
||||
const filteredIcons = useMemo(() => filterIcons(ICON_OPTIONS, search), [search])
|
||||
|
||||
@@ -41,10 +60,17 @@ export function TypeCustomizePopover({
|
||||
onChangeIcon(name)
|
||||
}
|
||||
|
||||
const debouncedSaveTemplate = useDebouncedCallback(onChangeTemplate, 500)
|
||||
|
||||
const handleTemplateChange = (value: string) => {
|
||||
setTemplateText(value)
|
||||
debouncedSaveTemplate(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-popover text-popover-foreground z-50 rounded-lg border shadow-md"
|
||||
style={{ width: 280, padding: 12 }}
|
||||
style={{ width: 320, padding: 12 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -84,7 +110,7 @@ export function TypeCustomizePopover({
|
||||
</div>
|
||||
|
||||
{/* Icon grid */}
|
||||
<div className="flex flex-wrap gap-1 overflow-y-auto" style={{ maxHeight: 240 }}>
|
||||
<div className="flex flex-wrap gap-1 overflow-y-auto" style={{ maxHeight: 160 }}>
|
||||
{filteredIcons.length === 0 ? (
|
||||
<div className="w-full py-6 text-center text-[12px] text-muted-foreground">
|
||||
No icons found
|
||||
@@ -109,6 +135,17 @@ export function TypeCustomizePopover({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Template section */}
|
||||
<div className="font-mono-overline mb-2 mt-3 text-muted-foreground">TEMPLATE</div>
|
||||
<textarea
|
||||
value={templateText}
|
||||
onChange={(e) => handleTemplateChange(e.target.value)}
|
||||
placeholder="Markdown template for new notes of this type…"
|
||||
className="w-full rounded border border-border bg-background px-2 py-1.5 text-[12px] font-mono text-foreground placeholder:text-muted-foreground outline-none focus:border-primary resize-y"
|
||||
style={{ minHeight: 80, maxHeight: 200 }}
|
||||
data-testid="template-textarea"
|
||||
/>
|
||||
|
||||
{/* Done button */}
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
|
||||
@@ -26,6 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -142,6 +143,43 @@ describe('useEntryActions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleUpdateTypeTemplate', () => {
|
||||
it('updates template on the type entry', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
act(() => {
|
||||
result.current.handleUpdateTypeTemplate('Project', '## Objective\n\n## Notes')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '## Objective\n\n## Notes')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: '## Objective\n\n## Notes' })
|
||||
})
|
||||
|
||||
it('sets template to null when empty string', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
act(() => {
|
||||
result.current.handleUpdateTypeTemplate('Project', '')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: null })
|
||||
})
|
||||
|
||||
it('does nothing when type entry not found', () => {
|
||||
const { result } = setup([])
|
||||
|
||||
act(() => {
|
||||
result.current.handleUpdateTypeTemplate('NonExistent', '## Template')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleReorderSections', () => {
|
||||
it('updates order on multiple type entries', () => {
|
||||
const typeA = makeEntry({ isA: 'Type', title: 'Note', path: '/vault/type/note.md' })
|
||||
|
||||
@@ -60,5 +60,12 @@ export function useEntryActions({
|
||||
}
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry])
|
||||
|
||||
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections }
|
||||
const handleUpdateTypeTemplate = useCallback((typeName: string, template: string) => {
|
||||
const typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) return
|
||||
handleUpdateFrontmatter(typeEntry.path, 'template', template)
|
||||
updateEntry(typeEntry.path, { template: template || null })
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry])
|
||||
|
||||
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate }
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
resolveDailyNote,
|
||||
findDailyNote,
|
||||
useNoteActions,
|
||||
DEFAULT_TEMPLATES,
|
||||
resolveTemplate,
|
||||
} from './useNoteActions'
|
||||
import type { NoteActionsConfig } from './useNoteActions'
|
||||
|
||||
@@ -57,6 +59,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
template: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -192,6 +195,47 @@ describe('buildNoteContent', () => {
|
||||
const content = buildNoteContent('AI', 'Topic', null)
|
||||
expect(content).toBe('---\ntitle: AI\ntype: Topic\n---\n\n# AI\n\n')
|
||||
})
|
||||
|
||||
it('includes template body when provided', () => {
|
||||
const content = buildNoteContent('My Project', 'Project', 'Active', '## Objective\n\n## Notes\n\n')
|
||||
expect(content).toContain('# My Project')
|
||||
expect(content).toContain('## Objective')
|
||||
expect(content).toContain('## Notes')
|
||||
})
|
||||
|
||||
it('ignores null template', () => {
|
||||
const content = buildNoteContent('My Note', 'Note', 'Active', null)
|
||||
expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n\n# My Note\n\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTemplate', () => {
|
||||
it('returns template from type entry when set', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', template: '## Ingredients\n\n## Steps\n\n' })
|
||||
expect(resolveTemplate([typeEntry], 'Recipe')).toBe('## Ingredients\n\n## Steps\n\n')
|
||||
})
|
||||
|
||||
it('falls back to DEFAULT_TEMPLATES for built-in types', () => {
|
||||
expect(resolveTemplate([], 'Project')).toBe(DEFAULT_TEMPLATES.Project)
|
||||
})
|
||||
|
||||
it('returns null when no template and no default', () => {
|
||||
expect(resolveTemplate([], 'CustomType')).toBeNull()
|
||||
})
|
||||
|
||||
it('type entry template overrides default', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom\n\n' })
|
||||
expect(resolveTemplate([typeEntry], 'Project')).toBe('## Custom\n\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DEFAULT_TEMPLATES', () => {
|
||||
it('has templates for Project, Person, Responsibility, Experiment', () => {
|
||||
expect(DEFAULT_TEMPLATES.Project).toBeDefined()
|
||||
expect(DEFAULT_TEMPLATES.Person).toBeDefined()
|
||||
expect(DEFAULT_TEMPLATES.Responsibility).toBeDefined()
|
||||
expect(DEFAULT_TEMPLATES.Experiment).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewNote', () => {
|
||||
@@ -244,6 +288,7 @@ describe('frontmatterToEntryPatch', () => {
|
||||
['archived', true, { archived: true }],
|
||||
['trashed', true, { trashed: true }],
|
||||
['order', 5, { order: 5 }],
|
||||
['template', '## Heading\n\n', { template: '## Heading\n\n' }],
|
||||
] as [string, unknown, Partial<VaultEntry>][])(
|
||||
'maps %s update to correct entry field',
|
||||
(key, value, expected) => {
|
||||
@@ -274,6 +319,7 @@ describe('frontmatterToEntryPatch', () => {
|
||||
['aliases', { aliases: [] }],
|
||||
['archived', { archived: false }],
|
||||
['order', { order: null }],
|
||||
['template', { template: null }],
|
||||
] as [string, Partial<VaultEntry>][])(
|
||||
'maps delete of %s to null/default',
|
||||
(key, expected) => {
|
||||
@@ -523,6 +569,43 @@ describe('useNoteActions hook', () => {
|
||||
expect(createdEntry.isA).toBe('Project')
|
||||
})
|
||||
|
||||
it('handleCreateNote uses default template for Project type', () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateNote('My Project', 'Project')
|
||||
})
|
||||
|
||||
const [, createdContent] = addEntry.mock.calls[0]
|
||||
expect(createdContent).toContain('## Objective')
|
||||
expect(createdContent).toContain('## Key Results')
|
||||
})
|
||||
|
||||
it('handleCreateNote uses custom template from type entry', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', template: '## Ingredients\n\n## Steps\n\n' })
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateNote('Pasta', 'Recipe')
|
||||
})
|
||||
|
||||
const [, createdContent] = addEntry.mock.calls[0]
|
||||
expect(createdContent).toContain('## Ingredients')
|
||||
expect(createdContent).toContain('## Steps')
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate uses template for typed notes', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom Template\n\n' })
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate('Project')
|
||||
})
|
||||
|
||||
const [, createdContent] = addEntry.mock.calls[0]
|
||||
expect(createdContent).toContain('## Custom Template')
|
||||
})
|
||||
|
||||
it('handleUpdateFrontmatter does not call updateEntry for unknown keys', async () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: now, createdAt: now, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,11 +129,26 @@ const TYPE_FOLDER_MAP: Record<string, string> = {
|
||||
|
||||
const NO_STATUS_TYPES = new Set(['Topic', 'Person', 'Journal'])
|
||||
|
||||
/** Default templates for built-in types. Used when the type entry has no custom template. */
|
||||
export const DEFAULT_TEMPLATES: Record<string, string> = {
|
||||
Project: '## Objective\n\n\n\n## Key Results\n\n\n\n## Notes\n\n',
|
||||
Person: '## Role\n\n\n\n## Contact\n\n\n\n## Notes\n\n',
|
||||
Responsibility: '## Description\n\n\n\n## Key Activities\n\n\n\n## Notes\n\n',
|
||||
Experiment: '## Hypothesis\n\n\n\n## Method\n\n\n\n## Results\n\n\n\n## Conclusion\n\n',
|
||||
}
|
||||
|
||||
/** Look up the template for a given type from the type entry or defaults. */
|
||||
export function resolveTemplate(entries: VaultEntry[], typeName: string): string | null {
|
||||
const typeEntry = entries.find(e => e.isA === 'Type' && e.title === typeName)
|
||||
return typeEntry?.template ?? DEFAULT_TEMPLATES[typeName] ?? null
|
||||
}
|
||||
|
||||
const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
||||
type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null },
|
||||
icon: { icon: null }, owner: { owner: null }, cadence: { cadence: null },
|
||||
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
|
||||
archived: { archived: false }, trashed: { trashed: false }, order: { order: null },
|
||||
template: { template: null },
|
||||
}
|
||||
|
||||
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
|
||||
@@ -150,6 +165,7 @@ export function frontmatterToEntryPatch(
|
||||
aliases: { aliases: arr }, belongs_to: { belongsTo: arr }, related_to: { relatedTo: arr },
|
||||
archived: { archived: Boolean(value) }, trashed: { trashed: Boolean(value) },
|
||||
order: { order: typeof value === 'number' ? value : null },
|
||||
template: { template: str },
|
||||
}
|
||||
return updates[k] ?? {}
|
||||
}
|
||||
@@ -159,19 +175,20 @@ function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: Vaul
|
||||
addEntry(entry, content)
|
||||
}
|
||||
|
||||
export function buildNoteContent(title: string, type: string, status: string | null): string {
|
||||
export function buildNoteContent(title: string, type: string, status: string | null, template?: string | null): string {
|
||||
const lines = ['---', `title: ${title}`, `type: ${type}`]
|
||||
if (status) lines.push(`status: ${status}`)
|
||||
lines.push('---')
|
||||
return `${lines.join('\n')}\n\n# ${title}\n\n`
|
||||
const body = template ? `\n${template}` : '\n'
|
||||
return `${lines.join('\n')}\n\n# ${title}\n${body}`
|
||||
}
|
||||
|
||||
export function resolveNewNote(title: string, type: string): { entry: VaultEntry; content: string } {
|
||||
export function resolveNewNote(title: string, type: string, template?: string | null): { entry: VaultEntry; content: string } {
|
||||
const folder = TYPE_FOLDER_MAP[type] || slugify(type)
|
||||
const slug = slugify(title)
|
||||
const status = NO_STATUS_TYPES.has(type) ? null : 'Active'
|
||||
const entry = buildNewEntry({ path: `/Users/luca/Laputa/${folder}/${slug}.md`, slug, title, type, status })
|
||||
return { entry, content: buildNoteContent(title, type, status) }
|
||||
return { entry, content: buildNoteContent(title, type, status, template) }
|
||||
}
|
||||
|
||||
export function resolveNewType(typeName: string): { entry: VaultEntry; content: string } {
|
||||
@@ -340,13 +357,17 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
[openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave], // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are
|
||||
)
|
||||
|
||||
const handleCreateNote = useCallback((title: string, type: string) => persistNew(resolveNewNote(title, type)), [persistNew])
|
||||
const handleCreateNote = useCallback((title: string, type: string) => {
|
||||
const template = resolveTemplate(entries, type)
|
||||
persistNew(resolveNewNote(title, type, template))
|
||||
}, [entries, persistNew])
|
||||
|
||||
const handleCreateNoteImmediate = useCallback((type?: string) => {
|
||||
const noteType = type || 'Note'
|
||||
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
|
||||
pendingNamesRef.current.add(title)
|
||||
const resolved = resolveNewNote(title, noteType)
|
||||
const template = resolveTemplate(entries, noteType)
|
||||
const resolved = resolveNewNote(title, noteType, template)
|
||||
openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
config.trackUnsaved?.(resolved.entry.path)
|
||||
|
||||
@@ -22,6 +22,7 @@ function makeEntry(path: string, title: string): VaultEntry {
|
||||
fileSize: 100,
|
||||
color: null,
|
||||
icon: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
relationships: {},
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ const mockEntries: VaultEntry[] = [
|
||||
status: 'Active', owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'],
|
||||
},
|
||||
{
|
||||
@@ -71,6 +72,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['essay/on-writing-well', 'essay/engineering-leadership-101', 'essay/ai-agents-primer', 'topic/growth', 'topic/writing'],
|
||||
},
|
||||
{
|
||||
@@ -100,6 +102,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['person/matteo-cellini'],
|
||||
},
|
||||
{
|
||||
@@ -129,6 +132,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
},
|
||||
{
|
||||
@@ -158,6 +162,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/manage-sponsorships'],
|
||||
},
|
||||
{
|
||||
@@ -188,6 +193,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'],
|
||||
},
|
||||
{
|
||||
@@ -218,6 +224,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'],
|
||||
},
|
||||
{
|
||||
@@ -247,6 +254,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['project/26q1-laputa-app'],
|
||||
},
|
||||
{
|
||||
@@ -275,6 +283,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -303,6 +312,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -331,6 +341,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -359,6 +370,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -388,6 +400,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'],
|
||||
},
|
||||
{
|
||||
@@ -417,6 +430,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -446,6 +460,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -475,6 +490,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
},
|
||||
{
|
||||
@@ -505,6 +521,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'],
|
||||
},
|
||||
{
|
||||
@@ -534,6 +551,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
},
|
||||
// --- Type documents ---
|
||||
@@ -561,6 +579,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 0,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -587,6 +606,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 1,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -613,6 +633,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 2,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -639,6 +660,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 3,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -665,6 +687,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 4,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -691,6 +714,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 5,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -717,6 +741,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 6,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -743,6 +768,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 7,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -769,6 +795,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 8,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
// --- Custom type documents ---
|
||||
@@ -796,6 +823,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: 'orange',
|
||||
order: 9,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -822,6 +850,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: 'green',
|
||||
order: 10,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
// --- Instances of custom types ---
|
||||
@@ -851,6 +880,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -879,6 +909,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
// --- Trashed entries ---
|
||||
@@ -909,6 +940,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -937,6 +969,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -966,6 +999,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
// --- Archived entries ---
|
||||
@@ -987,6 +1021,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
modifiedAt: now - 86400 * 120,
|
||||
createdAt: now - 86400 * 200,
|
||||
@@ -1016,6 +1051,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
modifiedAt: now - 86400 * 90,
|
||||
createdAt: now - 86400 * 150,
|
||||
@@ -1079,6 +1115,7 @@ function generateBulkEntries(count: number): VaultEntry[] {
|
||||
order: null,
|
||||
outgoingLinks: Array.from({ length: i % 8 }, (_j, j) => `note/link-target-${(i + j) % 50}`),
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
})
|
||||
}
|
||||
return entries
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface VaultEntry {
|
||||
order: number | null
|
||||
/** Custom sidebar section label for Type entries, overriding auto-pluralization. */
|
||||
sidebarLabel: string | null
|
||||
/** Markdown template for Type entries. Pre-fills new notes created with this type. */
|
||||
template: string | null
|
||||
/** All wikilink targets found in the note content. Extracted from [[target]] patterns. */
|
||||
outgoingLinks: string[]
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, outgoingLinks: [],
|
||||
icon: null, color: null, order: null, template: null, outgoingLinks: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: null, createdAt: null,
|
||||
fileSize: 0, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null,
|
||||
order: null, outgoingLinks: [],
|
||||
order: null, template: null, outgoingLinks: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ const baseEntry: VaultEntry = {
|
||||
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', relationships: {},
|
||||
wordCount: 0,
|
||||
icon: null, color: null, order: null, outgoingLinks: [],
|
||||
icon: null, color: null, order: null, template: null, outgoingLinks: [],
|
||||
}
|
||||
|
||||
describe('buildTypeEntryMap', () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user