fix: improve search UX with no-results feedback and hide empty groups

- Add "No tasks match..." message when search returns no results
- Hide empty groups in TaskListView when search filtering
- Persist search term across view re-renders
- Remove placeholder integration tests that weren't testing anything
This commit is contained in:
callumalpass 2025-11-25 20:52:45 +11:00
parent 26c2bbe3f8
commit 7abc899a4d
6 changed files with 106 additions and 212 deletions

View file

@ -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

View file

@ -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
/**

View file

@ -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;
}

View file

@ -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);

View file

@ -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);
}

View file

@ -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
});
});
});