diff --git a/src-tauri/src/vault/mod_tests.rs b/src-tauri/src/vault/mod_tests.rs index dd7ddc08..f0e567ac 100644 --- a/src-tauri/src/vault/mod_tests.rs +++ b/src-tauri/src/vault/mod_tests.rs @@ -486,6 +486,70 @@ References: ); } +// --- large relationship array (regression: No Code note with 32 Notes) --- + +#[test] +fn test_parse_large_notes_relationship_array() { + let dir = TempDir::new().unwrap(); + let content = r#"--- +type: Topic +Referred by Data: + - "[[michele-sampieri|Michele Sampieri]]" + - "[[varun-anand|Varun Anand]]" +Belongs to: + - "[[engineering|Engineering]]" +aliases: + - No Code +Notes: + - "[[8020-we-help-companies-move-faster-without-code|8020 | We help companies move faster without code.]]" + - "[[airdev-build-hub|Airdev Build Hub]]" + - "[[airdev-leader-in-bubble-and-no-code-development|AirDev | Leader in Bubble and No-Code Development]]" + - "[[budibase-internal-tools-made-easy|Budibase - Internal tools made easy]]" + - "[[bullet-launch-bubble-boilerplate|Bullet Launch Bubble Boilerplate]]" + - "[[canvas-base-template-for-bubble|Canvas • Base Template for Bubble]]" + - "[[chameleon-microsurveys|Chameleon | Microsurveys]]" + - "[[felt-the-best-way-to-make-maps-on-the-internet|Felt – The best way to make maps on the internet]]" + - "[[flutterflow-build-native-apps-visually|FlutterFlow | Build Native Apps Visually]]" + - "[[framer-ai-generate-and-publish-your-site-with-ai-in-seconds|Framer AI — Generate and publish your site with AI in seconds.]]" + - "[[jumpstart-pro-the-best-ruby-on-rails-saas-template|Jumpstart Pro | The best Ruby on Rails SaaS Template]]" + - "[[mailparser-email-parser-software-workflow-automation|MailParser • Email Parser Software & Workflow Automation]]" + - "[[make-work-the-way-you-imagine|Make | Work the way you imagine]]" + - "[[michele-sampieri|Michele Sampieri]]" + - "[[n8nio-a-powerful-workflow-automation-tool|n8n.io - a powerful workflow automation tool]]" + - "[[n8nio-ai-workflow-automation-tool|n8n.io - AI workflow automation tool]]" + - "[[nocodey-find-best-nocoder|Nocodey • Find Best Nocoder]]" + - "[[outseta-software-for-subscription-start-ups|Outseta | Software for subscription start-ups]]" + - "[[payments-tax-subscriptions-for-software-companies-lemon-squeezy|Payments, tax & subscriptions for software companies • Lemon Squeezy]]" + - "[[retool-portals-custom-client-portal-software|Retool Portals • Custom Client Portal Software]]" + - "[[rise-of-the-no-code-economy-report-formstack|Rise of the No-Code Economy Report | Formstack]]" + - "[[scene-the-smart-way-to-build-websites|Scene • The smart way to build websites]]" + - "[[scrapingbee-the-best-web-scraping-api|ScrapingBee • the best web scraping API]]" + - "[[softr-build-a-website-web-app-or-portal-on-airtable-without-code|Softr | Build a website, web app or portal on Airtable without code]]" + - "[[superblocks-build-modern-internal-apps-in-days-not-months|Superblocks • Build modern internal apps in days, not months]]" + - "[[superwall-quickly-deploy-paywalls|Superwall • Quickly deploy paywalls]]" + - "[[tails-tailwind-css-page-creator|Tails | Tailwind CSS Page Creator]]" + - "[[the-open-source-firebase-alternative-supabase|The Open Source Firebase Alternative | Supabase]]" + - "[[varun-anand|Varun Anand]]" + - "[[xano-the-fastest-no-code-backend-development-platform|Xano - The Fastest No Code Backend Development Platform]]" + - "[[directus-open-data-platform-for-headless-content-management|{'Directus': 'Open Data Platform for Headless Content Management'}]]" + - "[[framer-design-beautiful-websites-in-minutes|{'Framer': 'Design beautiful websites in minutes'}]]" +title: No Code +--- +# No Code +"#; + create_test_file(dir.path(), "no-code.md", content); + let entry = parse_md_file(&dir.path().join("no-code.md")).unwrap(); + + let notes = entry.relationships.get("Notes").expect("Notes relationship should exist"); + assert_eq!(notes.len(), 32, "All 32 Notes entries should be parsed"); + + let referred = entry.relationships.get("Referred by Data").expect("Referred by Data should exist"); + assert_eq!(referred.len(), 2); + + let belongs = entry.relationships.get("Belongs to").expect("Belongs to should exist"); + assert_eq!(belongs.len(), 1); +} + // --- type from frontmatter only (no folder inference) --- #[test] diff --git a/src/components/note-list/noteListHooks.ts b/src/components/note-list/noteListHooks.ts index 87d7c013..03b841d3 100644 --- a/src/components/note-list/noteListHooks.ts +++ b/src/components/note-list/noteListHooks.ts @@ -56,7 +56,10 @@ export function useNoteListData({ entries, selection, query, listSort, listDirec const searchedGroups = useMemo(() => { if (!isEntityView) return [] - const groups = buildRelationshipGroups(selection.entry, entries) + // Look up the fresh entry from the entries array to pick up relationship + // updates that happened after the selection was captured. + const freshEntry = entries.find((e) => e.path === selection.entry.path) ?? selection.entry + const groups = buildRelationshipGroups(freshEntry, entries) return filterGroupsByQuery(groups, query) }, [isEntityView, selection, entries, query]) diff --git a/src/hooks/frontmatterOps.ts b/src/hooks/frontmatterOps.ts index bf412cd6..c80dca9c 100644 --- a/src/hooks/frontmatterOps.ts +++ b/src/hooks/frontmatterOps.ts @@ -13,12 +13,38 @@ const ENTRY_DELETE_MAP: Record> = { template: { template: null }, sort: { sort: null }, visible: { visible: null }, } +/** Check if a string contains a wikilink pattern `[[...]]`. */ +function isWikilink(s: string): boolean { + return s.startsWith('[[') && s.includes(']]') +} + +/** Extract wikilink strings from a FrontmatterValue. Returns empty array if none. */ +function extractWikilinks(value: FrontmatterValue): string[] { + if (typeof value === 'string') return isWikilink(value) ? [value] : [] + if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string' && isWikilink(v)) + return [] +} + +/** + * Relationship patch: a partial update to merge into `entry.relationships`. + * Keys map to their new ref arrays. A `null` value means "remove this key". + */ +export type RelationshipPatch = Record + +export interface EntryPatchResult { + patch: Partial + relationshipPatch: RelationshipPatch | null +} + /** Map a frontmatter key+value to the corresponding VaultEntry field(s). */ export function frontmatterToEntryPatch( op: 'update' | 'delete', key: string, value?: FrontmatterValue, -): Partial { +): EntryPatchResult { const k = key.toLowerCase().replace(/\s+/g, '_') - if (op === 'delete') return ENTRY_DELETE_MAP[k] ?? {} + if (op === 'delete') { + const relPatch: RelationshipPatch = { [key]: null } + return { patch: ENTRY_DELETE_MAP[k] ?? {}, relationshipPatch: relPatch } + } const str = value != null ? String(value) : null const arr = Array.isArray(value) ? value.map(String) : [] const updates: Record> = { @@ -32,7 +58,11 @@ export function frontmatterToEntryPatch( view: { view: str }, visible: { visible: value === false ? false : null }, } - return updates[k] ?? {} + // Also update the relationships map for wikilink-containing values + const wikilinks = value != null ? extractWikilinks(value) : [] + const relationshipPatch: RelationshipPatch | null = + wikilinks.length > 0 ? { [key]: wikilinks } : null + return { patch: updates[k] ?? {}, relationshipPatch } } async function invokeFrontmatter(command: string, args: Record): Promise { @@ -65,18 +95,42 @@ export interface FrontmatterOpOptions { silent?: boolean } +/** Apply a relationship patch by merging into the existing relationships map. */ +export function applyRelationshipPatch( + existing: Record, relPatch: RelationshipPatch, +): Record { + const merged = { ...existing } + for (const [k, v] of Object.entries(relPatch)) { + if (v === null) delete merged[k] + else merged[k] = v + } + return merged +} + /** Run a frontmatter update/delete and apply the result to state. * Returns the new file content on success, or undefined on failure. */ export async function runFrontmatterAndApply( op: 'update' | 'delete', path: string, key: string, value: FrontmatterValue | undefined, - callbacks: { updateTab: (p: string, c: string) => void; updateEntry: (p: string, patch: Partial) => void; toast: (m: string | null) => void }, + callbacks: { + updateTab: (p: string, c: string) => void + updateEntry: (p: string, patch: Partial) => void + toast: (m: string | null) => void + getEntry?: (p: string) => VaultEntry | undefined + }, options?: FrontmatterOpOptions, ): Promise { try { const newContent = await executeFrontmatterOp(op, path, key, value) callbacks.updateTab(path, newContent) - const patch = frontmatterToEntryPatch(op, key, value) - if (Object.keys(patch).length > 0) callbacks.updateEntry(path, patch) + const { patch, relationshipPatch } = frontmatterToEntryPatch(op, key, value) + const fullPatch = { ...patch } + if (relationshipPatch && callbacks.getEntry) { + const current = callbacks.getEntry(path) + if (current) { + fullPatch.relationships = applyRelationshipPatch(current.relationships, relationshipPatch) + } + } + if (Object.keys(fullPatch).length > 0) callbacks.updateEntry(path, fullPatch) if (!options?.silent) callbacks.toast(op === 'update' ? 'Property updated' : 'Property deleted') return newContent } catch (err) { diff --git a/src/hooks/useNoteActions.test.ts b/src/hooks/useNoteActions.test.ts index 7eb82602..07888cd4 100644 --- a/src/hooks/useNoteActions.test.ts +++ b/src/hooks/useNoteActions.test.ts @@ -19,7 +19,7 @@ import { resolveTemplate, } from './useNoteCreation' import { needsRenameOnSave } from './useNoteRename' -import { frontmatterToEntryPatch } from './frontmatterOps' +import { frontmatterToEntryPatch, applyRelationshipPatch } from './frontmatterOps' import { useNoteActions } from './useNoteActions' import type { NoteActionsConfig } from './useNoteActions' @@ -345,25 +345,45 @@ describe('frontmatterToEntryPatch', () => { ] as [string, unknown, Partial][])( 'maps %s update to correct entry field', (key, value, expected) => { - expect(frontmatterToEntryPatch('update', key, value as never)).toEqual(expected) + expect(frontmatterToEntryPatch('update', key, value as never).patch).toEqual(expected) }, ) it('maps aliases update with array value', () => { - expect(frontmatterToEntryPatch('update', 'aliases', ['A', 'B'])).toEqual({ aliases: ['A', 'B'] }) + expect(frontmatterToEntryPatch('update', 'aliases', ['A', 'B']).patch).toEqual({ aliases: ['A', 'B'] }) }) it('maps belongs_to update with array value', () => { - expect(frontmatterToEntryPatch('update', 'belongs_to', ['[[parent]]'])).toEqual({ belongsTo: ['[[parent]]'] }) + const result = frontmatterToEntryPatch('update', 'belongs_to', ['[[parent]]']) + expect(result.patch).toEqual({ belongsTo: ['[[parent]]'] }) + // Also produces a relationship patch for the wikilink + expect(result.relationshipPatch).toEqual({ 'belongs_to': ['[[parent]]'] }) }) it('handles case-insensitive keys with spaces (e.g. "Is A", "Belongs to")', () => { - expect(frontmatterToEntryPatch('update', 'Is A', 'Experiment')).toEqual({ isA: 'Experiment' }) - expect(frontmatterToEntryPatch('update', 'Belongs to', ['[[x]]'])).toEqual({ belongsTo: ['[[x]]'] }) + expect(frontmatterToEntryPatch('update', 'Is A', 'Experiment').patch).toEqual({ isA: 'Experiment' }) + const result = frontmatterToEntryPatch('update', 'Belongs to', ['[[x]]']) + expect(result.patch).toEqual({ belongsTo: ['[[x]]'] }) + expect(result.relationshipPatch).toEqual({ 'Belongs to': ['[[x]]'] }) }) - it('returns empty object for unknown keys', () => { - expect(frontmatterToEntryPatch('update', 'custom_field', 'value')).toEqual({}) + it('returns empty patch for unknown non-wikilink keys', () => { + const result = frontmatterToEntryPatch('update', 'custom_field', 'value') + expect(result.patch).toEqual({}) + // Non-wikilink value → no relationship change + expect(result.relationshipPatch).toBeNull() + }) + + it('produces relationship patch for wikilink values on unknown keys', () => { + const result = frontmatterToEntryPatch('update', 'Notes', ['[[note-a]]', '[[note-b|Note B]]']) + expect(result.patch).toEqual({}) + expect(result.relationshipPatch).toEqual({ Notes: ['[[note-a]]', '[[note-b|Note B]]'] }) + }) + + it('produces relationship patch for single wikilink string', () => { + const result = frontmatterToEntryPatch('update', 'Owner', '[[person/alice]]') + expect(result.patch).toEqual({}) + expect(result.relationshipPatch).toEqual({ Owner: ['[[person/alice]]'] }) }) it.each([ @@ -377,12 +397,46 @@ describe('frontmatterToEntryPatch', () => { ] as [string, Partial][])( 'maps delete of %s to null/default', (key, expected) => { - expect(frontmatterToEntryPatch('delete', key)).toEqual(expected) + expect(frontmatterToEntryPatch('delete', key).patch).toEqual(expected) }, ) - it('returns empty object for unknown key on delete', () => { - expect(frontmatterToEntryPatch('delete', 'unknown_key')).toEqual({}) + it('returns empty patch for unknown key on delete, with relationship removal', () => { + const result = frontmatterToEntryPatch('delete', 'unknown_key') + expect(result.patch).toEqual({}) + expect(result.relationshipPatch).toEqual({ unknown_key: null }) + }) + + it('delete of known key also produces relationship removal', () => { + const result = frontmatterToEntryPatch('delete', 'status') + expect(result.patch).toEqual({ status: null }) + expect(result.relationshipPatch).toEqual({ status: null }) + }) +}) + +describe('applyRelationshipPatch', () => { + it('adds new relationship key', () => { + const existing = { 'Belongs to': ['[[eng]]'] } + const result = applyRelationshipPatch(existing, { Notes: ['[[note-a]]', '[[note-b]]'] }) + expect(result).toEqual({ 'Belongs to': ['[[eng]]'], Notes: ['[[note-a]]', '[[note-b]]'] }) + }) + + it('overwrites existing relationship key', () => { + const existing = { Notes: ['[[old]]'] } + const result = applyRelationshipPatch(existing, { Notes: ['[[new-a]]', '[[new-b]]'] }) + expect(result).toEqual({ Notes: ['[[new-a]]', '[[new-b]]'] }) + }) + + it('removes relationship key when value is null', () => { + const existing = { Notes: ['[[a]]'], Owner: ['[[alice]]'] } + const result = applyRelationshipPatch(existing, { Notes: null }) + expect(result).toEqual({ Owner: ['[[alice]]'] }) + }) + + it('does not mutate the original map', () => { + const existing = { Notes: ['[[a]]'] } + applyRelationshipPatch(existing, { Notes: ['[[b]]'] }) + expect(existing).toEqual({ Notes: ['[[a]]'] }) }) }) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index be1ea12e..596dedbb 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -117,8 +117,8 @@ export function useNoteActions(config: NoteActionsConfig) { const runFrontmatterOp = useCallback( (op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue, options?: FrontmatterOpOptions) => - runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage }, options), - [updateTabContent, updateEntry, setToastMessage], + runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) }, options), + [updateTabContent, updateEntry, setToastMessage, entries], ) return { diff --git a/src/utils/noteListHelpers.test.ts b/src/utils/noteListHelpers.test.ts index 617a0b76..35421f6a 100644 --- a/src/utils/noteListHelpers.test.ts +++ b/src/utils/noteListHelpers.test.ts @@ -324,6 +324,50 @@ describe('buildRelationshipGroups', () => { const groups = buildRelationshipGroups(entity, [entity, linker]) expect(groups.find((g) => g.label === 'Backlinks')!.entries[0].title).toBe('Linker') }) + + it('resolves all entries in a large Notes relationship (regression: No Code)', () => { + // Simulates the No Code topic note with 32 Notes, 2 Referred by Data, 1 Belongs to + const noteRefs = [ + '8020', 'airdev-build-hub', 'airdev-leader', 'budibase', 'bullet-launch', + 'canvas', 'chameleon', 'felt', 'flutterflow', 'framer-ai', + 'jumpstart', 'mailparser', 'make', 'michele-sampieri', 'n8n-a', + 'n8n-ai', 'nocodey', 'outseta', 'lemon-squeezy', 'retool', + 'rise-no-code', 'scene', 'scrapingbee', 'softr', 'superblocks', + 'superwall', 'tails', 'supabase', 'varun-anand', 'xano', + 'directus', 'framer-design', + ] + const noteEntries = noteRefs.map((slug, i) => makeEntry({ + path: `/Laputa/${slug}.md`, filename: `${slug}.md`, title: `Title ${slug}`, + modifiedAt: 1700000000 - i * 100, + })) + const engineering = makeEntry({ + path: '/Laputa/engineering.md', filename: 'engineering.md', title: 'Engineering', + modifiedAt: 1700000000, + }) + const entity = makeEntry({ + path: '/Laputa/no-code.md', filename: 'no-code.md', title: 'No Code', + isA: 'Topic', + relationships: { + 'Belongs to': ['[[engineering|Engineering]]'], + Notes: noteRefs.map((slug) => `[[${slug}|Title ${slug}]]`), + 'Referred by Data': ['[[michele-sampieri|Michele Sampieri]]', '[[varun-anand|Varun Anand]]'], + }, + }) + const allEntries = [entity, engineering, ...noteEntries] + const groups = buildRelationshipGroups(entity, allEntries) + + const belongsGroup = groups.find((g) => g.label === 'Belongs to') + expect(belongsGroup).toBeDefined() + expect(belongsGroup!.entries).toHaveLength(1) + + const notesGroup = groups.find((g) => g.label === 'Notes') + expect(notesGroup).toBeDefined() + expect(notesGroup!.entries).toHaveLength(32) + + // michele-sampieri and varun-anand already consumed by Notes → Referred by Data has 0 new + const referredGroup = groups.find((g) => g.label === 'Referred by Data') + expect(referredGroup).toBeUndefined() + }) }) describe('getSortComparator — custom properties', () => { diff --git a/tests/smoke/note-list-incomplete-relationships.spec.ts b/tests/smoke/note-list-incomplete-relationships.spec.ts new file mode 100644 index 00000000..9423090e --- /dev/null +++ b/tests/smoke/note-list-incomplete-relationships.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from '@playwright/test' +import { sendShortcut } from './helpers' + +test.describe('Note list shows complete relationships when opening from sidebar', () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('entity view shows relationship groups with correct counts after sidebar click', async ({ page }) => { + // Navigate to Responsibilities type to find entity notes in the sidebar + // First, click on a sidebar type that contains entity notes + // We'll use the sidebar search to filter to "Sponsorships" + const searchToggle = page.locator('[data-testid="search-toggle"]') + if (await searchToggle.isVisible()) { + await searchToggle.click() + } + + // Type in sidebar search to find Sponsorships + const sidebarSearch = page.locator('[data-testid="note-list-search"]') + if (await sidebarSearch.isVisible()) { + await sidebarSearch.fill('Sponsorships') + await page.waitForTimeout(500) + } + + // Click the Sponsorships note in the note list to trigger entity view + const sponsorshipsItem = page.locator('[data-entry-path*="sponsorships"]').first() + if (await sponsorshipsItem.isVisible({ timeout: 3000 }).catch(() => false)) { + await sponsorshipsItem.click() + await page.waitForTimeout(1000) + } else { + // Fallback: open via quick open — then click in sidebar + await page.locator('body').click() + await sendShortcut(page, 'p', ['Control']) + const searchInput = page.locator('input[placeholder="Search notes..."]') + await expect(searchInput).toBeVisible() + await searchInput.fill('Sponsorships') + await page.waitForTimeout(500) + await page.keyboard.press('Enter') + await page.waitForTimeout(1000) + } + + // The inspector panel (right side) should show relationship labels with font-mono-overline + // This verifies the note loaded and relationships were parsed correctly + const measuresLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Has Measures' }) + await expect(measuresLabel).toBeVisible({ timeout: 5000 }) + + const proceduresLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Has Procedures' }) + await expect(proceduresLabel).toBeVisible({ timeout: 5000 }) + + // Verify the relationships panel shows the correct number of items + // Has Measures should have 2 entries (measure-sponsorship-mrr, measure-close-rate) + const measuresSection = measuresLabel.locator('xpath=ancestor::div[1]') + const measureItems = measuresSection.locator('a, button').filter({ hasText: /measure|Measure/ }) + // At least 1 resolved relationship entry + const hasItems = await measureItems.count() + expect(hasItems).toBeGreaterThanOrEqual(1) + }) + + test('relationship labels persist after navigating away and back', async ({ page }) => { + // Open Sponsorships via quick open + await page.locator('body').click() + await sendShortcut(page, 'p', ['Control']) + const searchInput = page.locator('input[placeholder="Search notes..."]') + await expect(searchInput).toBeVisible() + await searchInput.fill('Sponsorships') + await page.waitForTimeout(500) + await page.keyboard.press('Enter') + await page.waitForTimeout(1000) + + // Verify Has Measures relationship appears in inspector + const measuresLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Has Measures' }) + await expect(measuresLabel).toBeVisible({ timeout: 5000 }) + + // Navigate to different note + await page.locator('body').click() + await sendShortcut(page, 'p', ['Control']) + const searchInput2 = page.locator('input[placeholder="Search notes..."]') + await expect(searchInput2).toBeVisible() + await searchInput2.fill('Start Laputa App') + await page.waitForTimeout(500) + await page.keyboard.press('Enter') + await page.waitForTimeout(1000) + + // Navigate back to Sponsorships + await page.locator('body').click() + await sendShortcut(page, 'p', ['Control']) + const searchInput3 = page.locator('input[placeholder="Search notes..."]') + await expect(searchInput3).toBeVisible() + await searchInput3.fill('Sponsorships') + await page.waitForTimeout(500) + await page.keyboard.press('Enter') + await page.waitForTimeout(1000) + + // Relationships should still be visible — not stale or incomplete + const measuresLabelAgain = page.locator('span.font-mono-overline').filter({ hasText: 'Has Measures' }) + await expect(measuresLabelAgain).toBeVisible({ timeout: 5000 }) + + const proceduresLabelAgain = page.locator('span.font-mono-overline').filter({ hasText: 'Has Procedures' }) + await expect(proceduresLabelAgain).toBeVisible({ timeout: 5000 }) + }) +})