diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 2e31e961..f73f7a4c 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -24,6 +24,14 @@ Example: --> +## Added + +- (#1207) Added inline search box to Bases views (Task List, Kanban, Calendar) + - Enable via "Enable search box" toggle in view settings + - Searches across title, status, priority, tags, contexts, projects, and visible custom properties + - Press Escape or click × to clear search + - Thanks to @renatomen for the PR + ## Fixed - (#1165) Fixed Kanban view grouping by list properties (contexts, tags, projects) treating multiple values as a single combined column diff --git a/src/bases/BasesViewBase.ts b/src/bases/BasesViewBase.ts index ff68ef3c..c9f96850 100644 --- a/src/bases/BasesViewBase.ts +++ b/src/bases/BasesViewBase.ts @@ -358,8 +358,12 @@ export abstract class BasesViewBase extends Component { * Requires enableSearch to be true and will only create the UI once. */ protected setupSearch(container: HTMLElement): void { - // Idempotency: if search UI is already created, do nothing + // Idempotency: if search UI is already created, restore value and return if (this.searchBox) { + // Restore search term if it was cleared during re-render + if (this.currentSearchTerm && this.searchBox.getValue() !== this.currentSearchTerm) { + this.searchBox.setValue(this.currentSearchTerm); + } return; } if (!this.enableSearch) { @@ -399,6 +403,11 @@ export abstract class BasesViewBase extends Component { ); this.searchBox.render(); + // Restore search term if view is being re-initialized with existing search + if (this.currentSearchTerm) { + this.searchBox.setValue(this.currentSearchTerm); + } + // Register cleanup using Component lifecycle this.register(() => { if (this.searchBox) { @@ -455,6 +464,35 @@ export abstract class BasesViewBase extends Component { return filtered; } + /** + * Check if we're currently filtering with no results. + * Returns true if search is active and produced no matches. + */ + protected isSearchWithNoResults(filteredTasks: TaskInfo[], originalCount: number): boolean { + return this.currentSearchTerm.length > 0 && filteredTasks.length === 0 && originalCount > 0; + } + + /** + * Render "no results" message for search. + * Call this when search produces no matches. + */ + protected renderSearchNoResults(container: HTMLElement): void { + const noResultsEl = document.createElement("div"); + noResultsEl.className = "tn-search-no-results"; + + const textEl = document.createElement("div"); + textEl.className = "tn-search-no-results__text"; + textEl.textContent = `No tasks match "${this.currentSearchTerm}"`; + + const hintEl = document.createElement("div"); + hintEl.className = "tn-search-no-results__hint"; + hintEl.textContent = "Try a different search term or clear the search"; + + noResultsEl.appendChild(textEl); + noResultsEl.appendChild(hintEl); + container.appendChild(noResultsEl); + } + // Abstract methods that subclasses must implement /** diff --git a/src/bases/KanbanView.ts b/src/bases/KanbanView.ts index ba5cc8ae..8c1878ba 100644 --- a/src/bases/KanbanView.ts +++ b/src/bases/KanbanView.ts @@ -119,7 +119,12 @@ export class KanbanView extends BasesViewBase { this.boardEl.empty(); if (filteredTasks.length === 0) { - this.renderEmptyState(); + // Show "no results" if search returned empty but we had tasks + if (this.isSearchWithNoResults(filteredTasks, taskNotes.length)) { + this.renderSearchNoResults(this.boardEl); + } else { + this.renderEmptyState(); + } return; } diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 517ce3fe..867479a0 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -225,6 +225,15 @@ export class TaskListView extends BasesViewBase { // Apply search filter const filteredTasks = this.applySearchFilter(taskNotes); + // Show "no results" if search returned empty but we had tasks + if (this.isSearchWithNoResults(filteredTasks, taskNotes.length)) { + this.clearAllTaskElements(); + if (this.itemsContainer) { + this.renderSearchNoResults(this.itemsContainer); + } + return; + } + // Note: taskNotes are already sorted by Bases according to sort configuration // No manual sorting needed - Bases provides pre-sorted data @@ -376,6 +385,10 @@ export class TaskListView extends BasesViewBase { const primaryKey = this.dataAdapter.convertGroupKeyToString(group.key); const groupPaths = new Set(group.entries.map((e: any) => e.file.path)); const groupTasks = taskNotes.filter((t) => groupPaths.has(t.path)); + + // Skip groups with no matching tasks (e.g., after search filtering) + if (groupTasks.length === 0) continue; + const isPrimaryCollapsed = this.collapsedGroups.has(primaryKey); // Add primary header @@ -441,6 +454,15 @@ export class TaskListView extends BasesViewBase { // Apply search filter const filteredTasks = this.applySearchFilter(taskNotes); + // Show "no results" if search returned empty but we had tasks + if (this.isSearchWithNoResults(filteredTasks, taskNotes.length)) { + this.clearAllTaskElements(); + if (this.itemsContainer) { + this.renderSearchNoResults(this.itemsContainer); + } + return; + } + const targetDate = new Date(); this.currentTargetDate = targetDate; const cardOptions = this.getCardOptions(targetDate); @@ -518,6 +540,15 @@ export class TaskListView extends BasesViewBase { // Apply search filter const filteredTasks = this.applySearchFilter(taskNotes); + // Show "no results" if search returned empty but we had tasks + if (this.isSearchWithNoResults(filteredTasks, taskNotes.length)) { + this.clearAllTaskElements(); + if (this.itemsContainer) { + this.renderSearchNoResults(this.itemsContainer); + } + return; + } + const targetDate = new Date(); this.currentTargetDate = targetDate; const cardOptions = this.getCardOptions(targetDate); diff --git a/styles/search-box.css b/styles/search-box.css index 45ae307e..b17c9166 100644 --- a/styles/search-box.css +++ b/styles/search-box.css @@ -95,3 +95,25 @@ outline-offset: 2px; } +/* No results state */ +.tn-search-no-results { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--size-4-8, 32px) var(--size-4-4, 16px); + text-align: center; + color: var(--text-muted); +} + +.tn-search-no-results__text { + font-size: var(--font-ui-medium, 14px); + color: var(--text-normal); + margin-bottom: var(--size-4-2, 8px); +} + +.tn-search-no-results__hint { + font-size: var(--font-ui-small, 13px); + color: var(--text-muted); +} + diff --git a/tests/integration/TaskListView-search.integration.test.ts b/tests/integration/TaskListView-search.integration.test.ts deleted file mode 100644 index 98e20800..00000000 --- a/tests/integration/TaskListView-search.integration.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Integration tests for TaskListView search functionality - * - * Tests the integration of SearchBox and TaskSearchFilter with TaskListView - * including flat rendering, grouped rendering, and virtual scrolling scenarios. - */ - -import { TaskInfo } from '../../src/types'; - -describe('TaskListView Search Integration', () => { - let mockTasks: TaskInfo[]; - - beforeEach(() => { - // Create a larger set of mock tasks for testing - mockTasks = []; - - for (let i = 0; i < 150; i++) { - mockTasks.push({ - title: `Task ${i}`, - status: i % 3 === 0 ? 'done' : 'open', - priority: i % 2 === 0 ? 'high' : 'normal', - path: `tasks/task${i}.md`, - archived: false, - tags: i % 5 === 0 ? ['important'] : ['regular'], - contexts: i % 4 === 0 ? ['@work'] : ['@home'], - projects: i % 3 === 0 ? ['Project A'] : ['Project B'], - } as TaskInfo); - } - }); - - describe('flat rendering', () => { - it('should filter tasks in flat view', () => { - // This test would require mocking TaskListView - // For now, we're documenting the expected behavior - - // Expected: When search term is entered, only matching tasks are rendered - // Expected: Virtual scroller is updated with filtered tasks - // Expected: Task count reflects filtered results - - expect(true).toBe(true); // Placeholder - }); - - it('should update virtual scroller with filtered tasks', () => { - // Expected: When filtering reduces tasks below VIRTUAL_SCROLL_THRESHOLD, - // virtual scrolling should be disabled - // Expected: When filtering keeps tasks above threshold, virtual scrolling remains active - - expect(true).toBe(true); // Placeholder - }); - - it('should show all tasks when search is cleared', () => { - // Expected: Clearing search term restores all tasks - // Expected: Virtual scroller is updated with full task list - - expect(true).toBe(true); // Placeholder - }); - - it('should handle empty search results gracefully', () => { - // Expected: When no tasks match, show empty state - // Expected: Virtual scroller handles empty array - - expect(true).toBe(true); // Placeholder - }); - }); - - describe('grouped rendering', () => { - it('should filter tasks within groups', () => { - // Expected: Search filters tasks across all groups - // Expected: Groups with no matching tasks are hidden - // Expected: Group headers remain for groups with matches - - expect(true).toBe(true); // Placeholder - }); - - it('should hide empty groups after filtering', () => { - // Expected: Groups with zero matching tasks are not rendered - // Expected: Group count reflects only groups with matches - - expect(true).toBe(true); // Placeholder - }); - - it('should preserve group structure', () => { - // Expected: Filtered tasks remain in their original groups - // Expected: Group order is preserved - - expect(true).toBe(true); // Placeholder - }); - - it('should update virtual scroller with filtered grouped items', () => { - // Expected: Virtual scroller receives flattened list of headers + filtered tasks - // Expected: Scroll position is maintained when possible - - expect(true).toBe(true); // Placeholder - }); - }); - - describe('virtual scrolling', () => { - it('should update virtual scroller items on search', () => { - // Expected: VirtualScroller.updateItems() is called with filtered tasks - // Expected: Position cache is rebuilt for filtered set - - expect(true).toBe(true); // Placeholder - }); - - it('should recalculate scroll height', () => { - // Expected: Total scroll height reflects filtered task count - // Expected: Spacer element height is updated - - expect(true).toBe(true); // Placeholder - }); - - it('should handle switching between virtual and normal rendering', () => { - // Expected: Filtering 150 tasks to 50 switches to normal rendering - // Expected: Filtering 50 tasks to 150 switches to virtual rendering - - expect(true).toBe(true); // Placeholder - }); - - it('should maintain scroll position when filtering', () => { - // Expected: Scroll position is preserved when possible - // Expected: If filtered results are shorter, scroll to top - - expect(true).toBe(true); // Placeholder - }); - }); - - describe('data updates', () => { - it('should clear search when data is refreshed', () => { - // Expected: onDataUpdated() clears search term - // Expected: Search input is cleared - // Expected: All tasks are shown after refresh - - expect(true).toBe(true); // Placeholder - }); - - it('should preserve search when task is updated', () => { - // Expected: Individual task updates don't clear search - // Expected: Updated task is re-filtered - // Expected: Search results are updated if task no longer matches - - expect(true).toBe(true); // Placeholder - }); - - it('should handle task deletion during search', () => { - // Expected: Deleted task is removed from filtered results - // Expected: Search remains active - - expect(true).toBe(true); // Placeholder - }); - }); - - describe('performance', () => { - it('should complete search within acceptable time for 1000 tasks', () => { - // Create 1000 tasks - const largeMockTasks: TaskInfo[] = []; - for (let i = 0; i < 1000; i++) { - largeMockTasks.push({ - title: `Task ${i}`, - status: 'open', - priority: 'normal', - path: `tasks/task${i}.md`, - archived: false, - } as TaskInfo); - } - - // Expected: Search completes in < 200ms - // Expected: UI remains responsive - - expect(true).toBe(true); // Placeholder - }); - - it('should debounce rapid search input', () => { - // Expected: Multiple rapid inputs only trigger one search - // Expected: Debounce delay is 300ms - - expect(true).toBe(true); // Placeholder - }); - }); - - describe('edge cases', () => { - it('should handle special characters in search term', () => { - // Expected: Special regex characters are escaped - // Expected: Search works with characters like ., *, +, ?, etc. - - expect(true).toBe(true); // Placeholder - }); - - it('should handle very long search terms', () => { - // Expected: Long search terms don't cause performance issues - // Expected: Input field handles long text gracefully - - expect(true).toBe(true); // Placeholder - }); - - it('should handle tasks with missing fields', () => { - // Expected: Tasks with undefined/null fields don't cause errors - // Expected: Search works on available fields only - - expect(true).toBe(true); // Placeholder - }); - - it('should handle empty task list', () => { - // Expected: Search on empty list doesn't cause errors - // Expected: Empty state is shown - - expect(true).toBe(true); // Placeholder - }); - }); -}); -