mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
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:
parent
26c2bbe3f8
commit
7abc899a4d
6 changed files with 106 additions and 212 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in a new issue