feat: add saved views button position setting

Adds setting to position Saved Views button on left or right of filter bar. Maintains backward compatibility with right as default.
This commit is contained in:
Renato Mendonca 2025-08-15 12:50:30 +00:00 committed by GitHub
parent 9f27be5f05
commit a2aaab906c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1142 additions and 871 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

View file

@ -1,297 +1,315 @@
# Filtering and Views
TaskNotes provides filtering capabilities through the FilterBar, available in the Task List, Agenda, Kanban, and Advanced Calendar views. The FilterBar uses a hierarchical query builder to create complex filter conditions and supports saved views for quick access to common filter configurations.
## FilterBar Overview
The FilterBar is located at the top of supported views and provides three main sections:
1. **Quick Search**: Immediate text-based filtering
2. **Query Builder**: Hierarchical filter construction
3. **Display Options**: Sorting, grouping, and view-specific settings
4. **Saved Views**: Named filter configurations
## Quick Search
The search input provides instant filtering by task title:
- Type to search task titles in real-time
- Uses case-insensitive substring matching
- Updates with 800ms debouncing for performance
- Search terms appear as filter conditions in the query builder
### Search and Existing Filters
The search functionality intelligently preserves your existing filters:
**When you apply search to existing filters:**
1. All existing filters are automatically grouped together
2. The search condition is added as a separate filter
3. Search and existing filters are connected with "AND" logic
**When you clear search:**
- The search condition is removed
- Original filter structure is restored exactly as it was
**Example:**
If you have filters: `Priority = High OR Status = In Progress`
With search "urgent":
```
Search: title contains "urgent"
AND
Group: (Priority = High OR Status = In Progress)
```
This ensures search never interferes with your carefully crafted filter logic while providing powerful search capabilities on top of existing filters.
## Query Builder
The query builder allows construction of complex filter hierarchies using groups and conditions.
### Filter Groups
Filter groups are logical containers that can hold conditions or other groups:
- **Conjunction**: Choose AND or OR logic for combining contents
- **Nesting**: Groups can contain other groups for complex logic
- **Visual Hierarchy**: Indentation shows the structure
### Filter Conditions
Each condition consists of three parts:
1. **Property**: The task attribute to filter on
2. **Operator**: How to evaluate the property
3. **Value**: What to compare against (when applicable)
### Adding Filters
- **Add Filter**: Creates a new condition with default property and operator
- **Add Filter Group**: Creates a nested group with AND conjunction
- **Delete**: Remove individual conditions or entire groups
### Incomplete Conditions
The filter builder allows incomplete conditions during construction:
- Conditions missing values are ignored during evaluation
- This allows building complex filters step by step
- Only complete conditions affect the displayed tasks
## Available Properties
### Text Properties
- `title` - Task title/name
### Selection Properties
- `status` - Task status (uses your configured statuses)
- `priority` - Priority level (uses your configured priorities)
- `tags` - Task tags
- `contexts` - Task contexts
- `projects` - Task projects (supports `[[wiki-link]]` format)
### Date Properties
- `due` - Due date
- `scheduled` - Scheduled date
- `completedDate` - Date when task was completed
- `file.ctime` - File creation date
- `file.mtime` - File modification date
**Natural Language Date Support**: Date properties support both ISO date formats (`2024-12-25`, `2024-12-25T14:30:00`) and natural language patterns for dynamic filtering:
- **Basic dates**: `today`, `tomorrow`, `yesterday`
- **Week patterns**: `next week`, `last week`
- **Relative patterns**: `in 3 days`, `2 days ago`, `in 1 week`, `2 weeks ago`
Natural language dates are resolved dynamically when filters are evaluated, making saved views with dates like "today" stay current over time.
### Boolean Properties
- `archived` - Whether task is archived
- `status.isCompleted` - Whether the task's status indicates completion
### Numeric Properties
- `timeEstimate` - Time estimate in minutes
### Special Properties
- `recurrence` - Recurrence pattern (checks if pattern exists)
## Filter Operators
### Text Operators
- `contains` - Text contains substring (case-insensitive)
- `does-not-contain` - Text does not contain substring
### Comparison Operators
- `is` - Exact equality
- `is-not` - Exact inequality
- `is-greater-than` - Numeric greater than
- `is-less-than` - Numeric less than
### Date Operators
- `is-before` - Date is before specified date
- `is-after` - Date is after specified date
- `is-on-or-before` - Date is on or before specified date
- `is-on-or-after` - Date is on or after specified date
### Existence Operators
- `is-empty` - Property is null, undefined, or empty
- `is-not-empty` - Property has a value
- `is-checked` - Boolean property is true
- `is-not-checked` - Boolean property is false
## Value Inputs
The value input changes based on the selected property and operator:
**Text Input**: For text properties and custom values
**Dropdown Selection**: For status, priority, tags, contexts, and projects
**Date Input**: For date properties - supports both ISO dates and natural language
**Number Input**: For numeric properties
**No Input**: For existence operators that don't require values
### Date Input
Date inputs provide real-time validation with visual feedback:
- **Valid input**: Green border indicates recognized date format
- **Invalid input**: Red border indicates unrecognized format
- **Help tooltip**: Click the `?` button to see available natural language patterns
- **Smart filtering**: Only applies filters when input is valid or empty
Examples of valid date inputs:
- ISO formats: `2024-12-25`, `2024-12-25T14:30:00Z`
- Natural language: `today`, `next week`, `in 3 days`, `2 weeks ago`
## Saved Views
Saved views store complete filter configurations and view-specific options for quick access. This includes not only filters, sorting, and grouping, but also view-specific display preferences.
### What Gets Saved
When you save a view, the following state is preserved:
- **Filter Configuration**: All filter conditions, groups, and logic
- **Sorting**: Selected sort criteria and direction
- **Grouping**: Chosen grouping method
- **View Options**: View-specific display preferences such as:
- **Agenda View**: "Show overdue on today" and "Show notes" toggles
- **Advanced Calendar**: Display options for scheduled tasks, due dates, timeblocks, recurring tasks, ICS events, and time entries
### Saving Views
1. Configure your desired filters, sorting, and grouping
2. Set any view-specific options (toggles, display preferences)
3. Click the "Save View" button
4. Enter a name for the view
5. The complete view state is saved and appears in the dropdown
### Loading Views
1. Click the saved views dropdown
2. Select a view name
3. The complete configuration is applied, including:
- Filter conditions and structure
- Sorting and grouping settings
- View-specific display options
### Managing Views
- **Load**: Apply a saved view configuration
- **Delete**: Remove a saved view (requires confirmation)
- **Reorder**: Drag and drop saved views to reorder them
- Views persist across sessions using local storage
## Sorting
Available sort options:
- `due` - Due date (earliest first)
- `scheduled` - Scheduled date (earliest first)
- `priority` - Priority level (by configured weight)
- `title` - Alphabetical (A-Z)
- `createdDate` - Date created (newest first)
**Fallback Sorting**: When the primary sort criteria are equal, tasks are sorted by: scheduled → due → priority → title
## Grouping
Available grouping options:
- `none` - No grouping (flat list)
- `status` - Group by task status
- `priority` - Group by priority level
- `context` - Group by first context (tasks without contexts appear in "No Context")
- `project` - Group by project (tasks can appear in multiple groups)
- `due` - Group by due date ranges (Today, Tomorrow, This Week, etc.)
- `scheduled` - Group by scheduled date ranges
**Project Grouping**: Tasks with multiple projects appear in each project group.
## Performance Considerations
The FilterBar includes several performance optimizations:
- **Debounced Input**: Search (800ms) and filter changes (300ms) are debounced
- **Batch Loading**: Tasks are loaded in batches of 50
- **Smart Updates**: Only affected UI components re-render when filters change
- **Efficient Evaluation**: Empty groups and incomplete conditions are handled efficiently
## Filter Evaluation Logic
Understanding how filters are evaluated:
1. **Empty Groups**: Groups with no conditions return true (no filtering)
2. **Incomplete Conditions**: Conditions missing required values are ignored
3. **Array Properties**: For properties with multiple values (tags, contexts), any matching value satisfies the condition
4. **Wiki Links**: Project values in `[[link]]` format are automatically resolved
5. **Case Sensitivity**: Text matching is case-insensitive
6. **Date Precision**: Date comparisons account for time components
## Example Filter Scenarios
### Simple Text Search
- Property: `title`
- Operator: `contains`
- Value: `meeting`
### Dynamic Date Filters
Using natural language dates for filters that stay current:
- Property: `due`
- Operator: `is-on-or-after`
- Value: `today`
### Complex Date Range
Group with AND conjunction:
- Condition 1: `due` `is-on-or-after` `2024-01-01`
- Condition 2: `due` `is-on-or-before` `2024-01-31`
### This Week's Tasks
Using natural language for relative time periods:
- Property: `due`
- Operator: `is-on-or-after`
- Value: `next week`
### High Priority Incomplete Tasks
Group with AND conjunction:
- Condition 1: `priority` `is` `high`
- Condition 2: `status.isCompleted` `is-not-checked`
### Multiple Projects or Contexts
Group with OR conjunction:
- Condition 1: `projects` `contains` `[[Work Project]]`
- Condition 2: `contexts` `is` `work`
This filtering system provides the flexibility to create simple quick filters or complex multi-criteria queries while maintaining good performance and user experience.
# Filtering and Views
TaskNotes provides filtering capabilities through the FilterBar, available in the Task List, Agenda, Kanban, and Advanced Calendar views. The FilterBar uses a hierarchical query builder to create complex filter conditions and supports saved views for quick access to common filter configurations.
## FilterBar Overview
The FilterBar is located at the top of supported views and provides three main sections:
1. **Quick Search**: Immediate text-based filtering
2. **Query Builder**: Hierarchical filter construction
3. **Display Options**: Sorting, grouping, and view-specific settings
4. **Saved Views**: Named filter configurations
## Quick Search
The search input provides instant filtering by task title:
- Type to search task titles in real-time
- Uses case-insensitive substring matching
- Updates with 800ms debouncing for performance
- Search terms appear as filter conditions in the query builder
### Search and Existing Filters
The search functionality intelligently preserves your existing filters:
**When you apply search to existing filters:**
1. All existing filters are automatically grouped together
2. The search condition is added as a separate filter
3. Search and existing filters are connected with "AND" logic
**When you clear search:**
- The search condition is removed
- Original filter structure is restored exactly as it was
**Example:**
If you have filters: `Priority = High OR Status = In Progress`
With search "urgent":
```
Search: title contains "urgent"
AND
Group: (Priority = High OR Status = In Progress)
```
This ensures search never interferes with your carefully crafted filter logic while providing powerful search capabilities on top of existing filters.
## Query Builder
The query builder allows construction of complex filter hierarchies using groups and conditions.
### Filter Groups
Filter groups are logical containers that can hold conditions or other groups:
- **Conjunction**: Choose AND or OR logic for combining contents
- **Nesting**: Groups can contain other groups for complex logic
- **Visual Hierarchy**: Indentation shows the structure
### Filter Conditions
Each condition consists of three parts:
1. **Property**: The task attribute to filter on
2. **Operator**: How to evaluate the property
3. **Value**: What to compare against (when applicable)
### Adding Filters
- **Add Filter**: Creates a new condition with default property and operator
- **Add Filter Group**: Creates a nested group with AND conjunction
- **Delete**: Remove individual conditions or entire groups
### Incomplete Conditions
The filter builder allows incomplete conditions during construction:
- Conditions missing values are ignored during evaluation
- This allows building complex filters step by step
- Only complete conditions affect the displayed tasks
## Available Properties
### Text Properties
- `title` - Task title/name
### Selection Properties
- `status` - Task status (uses your configured statuses)
- `priority` - Priority level (uses your configured priorities)
- `tags` - Task tags
- `contexts` - Task contexts
- `projects` - Task projects (supports `[[wiki-link]]` format)
### Date Properties
- `due` - Due date
- `scheduled` - Scheduled date
- `completedDate` - Date when task was completed
- `file.ctime` - File creation date
- `file.mtime` - File modification date
**Natural Language Date Support**: Date properties support both ISO date formats (`2024-12-25`, `2024-12-25T14:30:00`) and natural language patterns for dynamic filtering:
- **Basic dates**: `today`, `tomorrow`, `yesterday`
- **Week patterns**: `next week`, `last week`
- **Relative patterns**: `in 3 days`, `2 days ago`, `in 1 week`, `2 weeks ago`
Natural language dates are resolved dynamically when filters are evaluated, making saved views with dates like "today" stay current over time.
### Boolean Properties
- `archived` - Whether task is archived
- `status.isCompleted` - Whether the task's status indicates completion
### Numeric Properties
- `timeEstimate` - Time estimate in minutes
### Special Properties
- `recurrence` - Recurrence pattern (checks if pattern exists)
## Filter Operators
### Text Operators
- `contains` - Text contains substring (case-insensitive)
- `does-not-contain` - Text does not contain substring
### Comparison Operators
- `is` - Exact equality
- `is-not` - Exact inequality
- `is-greater-than` - Numeric greater than
- `is-less-than` - Numeric less than
### Date Operators
- `is-before` - Date is before specified date
- `is-after` - Date is after specified date
- `is-on-or-before` - Date is on or before specified date
- `is-on-or-after` - Date is on or after specified date
### Existence Operators
- `is-empty` - Property is null, undefined, or empty
- `is-not-empty` - Property has a value
- `is-checked` - Boolean property is true
- `is-not-checked` - Boolean property is false
## Value Inputs
The value input changes based on the selected property and operator:
**Text Input**: For text properties and custom values
**Dropdown Selection**: For status, priority, tags, contexts, and projects
**Date Input**: For date properties - supports both ISO dates and natural language
**Number Input**: For numeric properties
**No Input**: For existence operators that don't require values
### Date Input
Date inputs provide real-time validation with visual feedback:
- **Valid input**: Green border indicates recognized date format
- **Invalid input**: Red border indicates unrecognized format
- **Help tooltip**: Click the `?` button to see available natural language patterns
- **Smart filtering**: Only applies filters when input is valid or empty
Examples of valid date inputs:
- ISO formats: `2024-12-25`, `2024-12-25T14:30:00Z`
- Natural language: `today`, `next week`, `in 3 days`, `2 weeks ago`
## Saved Views
Saved views store complete filter configurations and view-specific options for quick access. This includes not only filters, sorting, and grouping, but also view-specific display preferences.
### What Gets Saved
When you save a view, the following state is preserved:
- **Filter Configuration**: All filter conditions, groups, and logic
- **Sorting**: Selected sort criteria and direction
- **Grouping**: Chosen grouping method
- **View Options**: View-specific display preferences such as:
- **Agenda View**: "Show overdue on today" and "Show notes" toggles
- **Advanced Calendar**: Display options for scheduled tasks, due dates, timeblocks, recurring tasks, ICS events, and time entries
### Saving Views
1. Configure your desired filters, sorting, and grouping
2. Set any view-specific options (toggles, display preferences)
3. Click the "Save View" button
4. Enter a name for the view
5. The complete view state is saved and appears in the dropdown
### Loading Views
1. Click the saved views dropdown
2. Select a view name
3. The complete configuration is applied, including:
- Filter conditions and structure
- Sorting and grouping settings
- View-specific display options
### Managing Views
- **Load**: Apply a saved view configuration
- **Delete**: Remove a saved view (requires confirmation)
- **Reorder**: Drag and drop saved views to reorder them
- Views persist across sessions using local storage
### Saved Views Button Position
You can choose where the Saved Views button appears in the FilterBar:
- Right (default): Filter → Search → Saved Views
- Left: Saved Views → Filter → Search
This is configured via Settings → Misc → Saved Views button position.
Right position (default):
![Saved Views button on the right](assets/saved_views_button_collapse_all.gif)
Left position:
![Saved Views button on the left](assets/saved_views_button_no_expand_collapse_all.gif)
## Sorting
Available sort options:
- `due` - Due date (earliest first)
- `scheduled` - Scheduled date (earliest first)
- `priority` - Priority level (by configured weight)
- `title` - Alphabetical (A-Z)
- `createdDate` - Date created (newest first)
**Fallback Sorting**: When the primary sort criteria are equal, tasks are sorted by: scheduled → due → priority → title
## Grouping
Available grouping options:
- `none` - No grouping (flat list)
- `status` - Group by task status
- `priority` - Group by priority level
- `context` - Group by first context (tasks without contexts appear in "No Context")
- `project` - Group by project (tasks can appear in multiple groups)
- `due` - Group by due date ranges (Today, Tomorrow, This Week, etc.)
- `scheduled` - Group by scheduled date ranges
**Project Grouping**: Tasks with multiple projects appear in each project group.
## Performance Considerations
The FilterBar includes several performance optimizations:
- **Debounced Input**: Search (800ms) and filter changes (300ms) are debounced
- **Batch Loading**: Tasks are loaded in batches of 50
- **Smart Updates**: Only affected UI components re-render when filters change
- **Efficient Evaluation**: Empty groups and incomplete conditions are handled efficiently
## Filter Evaluation Logic
Understanding how filters are evaluated:
1. **Empty Groups**: Groups with no conditions return true (no filtering)
2. **Incomplete Conditions**: Conditions missing required values are ignored
3. **Array Properties**: For properties with multiple values (tags, contexts), any matching value satisfies the condition
4. **Wiki Links**: Project values in `[[link]]` format are automatically resolved
5. **Case Sensitivity**: Text matching is case-insensitive
6. **Date Precision**: Date comparisons account for time components
## Example Filter Scenarios
### Simple Text Search
- Property: `title`
- Operator: `contains`
- Value: `meeting`
### Dynamic Date Filters
Using natural language dates for filters that stay current:
- Property: `due`
- Operator: `is-on-or-after`
- Value: `today`
### Complex Date Range
Group with AND conjunction:
- Condition 1: `due` `is-on-or-after` `2024-01-01`
- Condition 2: `due` `is-on-or-before` `2024-01-31`
### This Week's Tasks
Using natural language for relative time periods:
- Property: `due`
- Operator: `is-on-or-after`
- Value: `next week`
### High Priority Incomplete Tasks
Group with AND conjunction:
- Condition 1: `priority` `is` `high`
- Condition 2: `status.isCompleted` `is-not-checked`
### Multiple Projects or Contexts
Group with OR conjunction:
- Condition 1: `projects` `contains` `[[Work Project]]`
- Condition 2: `contexts` `is` `work`
This filtering system provides the flexibility to create simple quick filters or complex multi-criteria queries while maintaining good performance and user experience.

View file

@ -2,6 +2,16 @@
These settings control various plugin features and display options that don't fit into other categories.
## Saved Views button position
Choose where the Saved Views button appears in the FilterBar of supported views (Task List, Agenda, Kanban, Advanced Calendar):
- Right (default): Filter → Search → Saved Views
- Left: Saved Views → Filter → Search
This setting affects layout only; functionality is the same. See examples in Filtering and Views.
## Status Bar
**Show tracked tasks in status bar** - Display currently tracked tasks (with active time tracking) in the status bar at the bottom of the app. This provides a quick visual indicator of which tasks are currently being tracked without needing to open the TaskNotes views.

View file

@ -181,7 +181,8 @@ class ProjectSubtasksWidget extends WidgetType {
this.plugin.app,
container,
this.currentQuery,
filterOptions
filterOptions,
this.plugin.settings.viewsButtonAlignment || 'right'
);
// Load saved views from the main ViewStateManager

View file

@ -198,6 +198,9 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = {
showProjectSubtasks: true,
showExpandableSubtasks: true,
projectSubtasksPosition: 'bottom',
// Filter toolbar layout defaults
viewsButtonAlignment: 'right',
// Overdue behavior defaults
hideCompletedFromOverdue: true,
// ICS integration defaults

File diff suppressed because it is too large Load diff

36
src/types/chrono-node.d.ts vendored Normal file
View file

@ -0,0 +1,36 @@
declare module 'chrono-node' {
export interface ParsedComponents {
date(): Date;
get(component: string): number;
isCertain(component: string): boolean;
}
export interface ParsedResult {
text: string;
index: number;
start: ParsedComponents;
end?: ParsedComponents;
}
export function parse(text: string, refDate?: Date, options?: any): ParsedResult[];
export function parseDate(text: string, refDate?: Date, options?: any): Date | null;
export const casual: {
parse: typeof parse;
parseDate: typeof parseDate;
};
export const strict: {
parse: typeof parse;
parseDate: typeof parseDate;
};
const _default: {
parse: typeof parse;
parseDate: typeof parseDate;
casual: typeof casual;
strict: typeof strict;
};
export default _default;
}

View file

@ -54,6 +54,9 @@ export interface TaskNotesSettings {
showProjectSubtasks: boolean;
showExpandableSubtasks: boolean;
projectSubtasksPosition: 'top' | 'bottom';
// Filter toolbar layout
viewsButtonAlignment: 'left' | 'right';
// Overdue behavior settings
hideCompletedFromOverdue: boolean;
// ICS integration settings

View file

@ -68,6 +68,8 @@ export class FilterBar extends EventEmitter {
private displaySection?: HTMLElement;
private viewOptionsContainer?: HTMLElement;
private searchInput?: TextComponent;
private viewsButtonAlignment: 'left' | 'right' = 'right';
private isUserTyping = false;
private viewOptionsConfig: Array<{id: string, label: string, value: boolean, onChange: (value: boolean) => void}> | null = null;
private dragDropHandler: DragDropHandler;
@ -90,13 +92,15 @@ export class FilterBar extends EventEmitter {
app: App,
container: HTMLElement,
initialQuery: FilterQuery,
filterOptions: FilterOptions
filterOptions: FilterOptions,
viewsButtonAlignment: 'left' | 'right' = 'right'
) {
super();
this.app = app;
this.container = container;
this.currentQuery = FilterUtils.deepCloneFilterQuery(initialQuery);
this.filterOptions = filterOptions;
this.viewsButtonAlignment = viewsButtonAlignment;
// Initialize drag and drop handler
this.dragDropHandler = new DragDropHandler((fromIndex, toIndex) => {
@ -133,18 +137,18 @@ export class FilterBar extends EventEmitter {
*/
private ensureValidFilterQuery(): void {
// Check if query has the new FilterGroup structure
if (!this.currentQuery ||
if (!this.currentQuery ||
typeof this.currentQuery !== 'object' ||
this.currentQuery.type !== 'group' ||
!Array.isArray(this.currentQuery.children) ||
typeof this.currentQuery.conjunction !== 'string') {
// Create a fresh default query, preserving any sort/group settings if valid
const sortKey = (this.currentQuery?.sortKey && typeof this.currentQuery.sortKey === 'string') ? this.currentQuery.sortKey : 'due';
const sortDirection = (this.currentQuery?.sortDirection && typeof this.currentQuery.sortDirection === 'string') ? this.currentQuery.sortDirection : 'asc';
const groupKey = (this.currentQuery?.groupKey && typeof this.currentQuery.groupKey === 'string') ? this.currentQuery.groupKey : 'none';
this.currentQuery = {
type: 'group',
id: FilterUtils.generateId(),
@ -177,21 +181,21 @@ export class FilterBar extends EventEmitter {
private handleSearchInput(): void {
try {
const searchTerm = this.searchInput?.getValue().trim() || '';
// Remove existing search conditions and reorganize query structure
this.removeSearchConditions();
// Add new search condition if term is not empty
if (searchTerm) {
this.addSearchConditionWithGrouping(searchTerm);
}
// Update only the filter builder to show the search condition
this.updateFilterBuilder();
// Emit query change
// Emit query change
this.emit('queryChange', FilterUtils.deepCloneFilterQuery(this.currentQuery));
// Reset typing flag after a delay
setTimeout(() => {
this.isUserTyping = false;
@ -210,12 +214,12 @@ export class FilterBar extends EventEmitter {
this.currentQuery.children = [];
return;
}
// Find the search condition
const searchConditionIndex = this.currentQuery.children.findIndex(child =>
child.type === 'condition' &&
child.property === 'title' &&
child.operator === 'contains' &&
const searchConditionIndex = this.currentQuery.children.findIndex(child =>
child.type === 'condition' &&
child.property === 'title' &&
child.operator === 'contains' &&
child.id.startsWith('search_')
);
@ -229,11 +233,11 @@ export class FilterBar extends EventEmitter {
// Check if we have a structure where the remaining child is a single group
// that was created to preserve existing filters when search was added
if (this.currentQuery.children.length === 1 &&
if (this.currentQuery.children.length === 1 &&
this.currentQuery.children[0].type === 'group') {
const remainingGroup = this.currentQuery.children[0] as FilterGroup;
// Restore the original filter structure by moving the group's children up to the root
this.currentQuery.children = remainingGroup.children;
this.currentQuery.conjunction = remainingGroup.conjunction;
@ -248,7 +252,7 @@ export class FilterBar extends EventEmitter {
if (!Array.isArray(this.currentQuery.children)) {
this.currentQuery.children = [];
}
const searchCondition: FilterCondition = {
type: 'condition',
id: `search_${FilterUtils.generateId()}`,
@ -259,9 +263,9 @@ export class FilterBar extends EventEmitter {
// Get existing non-search filters
const existingFilters = this.currentQuery.children.filter(child => {
return !(child.type === 'condition' &&
child.property === 'title' &&
child.operator === 'contains' &&
return !(child.type === 'condition' &&
child.property === 'title' &&
child.operator === 'contains' &&
child.id.startsWith('search_'));
});
@ -345,44 +349,56 @@ export class FilterBar extends EventEmitter {
private renderTopControls(): void {
const topControls = this.container.createDiv('filter-bar__top-controls');
// Filter toggle icon
new ButtonComponent(topControls)
.setIcon('list-filter')
.setTooltip('Toggle filter')
.setClass('filter-bar__filter-toggle')
.onClick(() => {
this.toggleMainFilterBox();
const makeViewsButton = () => {
this.viewSelectorButton = new ButtonComponent(topControls)
.setButtonText('Views')
.setClass('filter-bar__templates-button')
.setTooltip('Saved filter views')
.onClick(() => {
this.toggleViewSelectorDropdown();
});
this.updateViewSelectorButtonState();
};
const makeFilterToggle = () => {
new ButtonComponent(topControls)
.setIcon('list-filter')
.setTooltip('Toggle filter')
.setClass('filter-bar__filter-toggle')
.onClick(() => {
this.toggleMainFilterBox();
});
};
const makeSearchInput = () => {
this.searchInput = new TextComponent(topControls)
.setPlaceholder('Search tasks...');
this.searchInput.inputEl.addClass('filter-bar__search-input');
setTooltip(this.searchInput.inputEl, 'Search task titles', { placement: 'top' });
this.searchInput.onChange(() => {
this.isUserTyping = true;
this.debouncedHandleSearchInput();
});
};
// Search input
this.searchInput = new TextComponent(topControls)
.setPlaceholder('Search tasks...');
this.searchInput.inputEl.addClass('filter-bar__search-input');
setTooltip(this.searchInput.inputEl, 'Search task titles', { placement: 'top' });
this.searchInput.onChange(() => {
this.isUserTyping = true;
this.debouncedHandleSearchInput();
});
// Templates button
this.viewSelectorButton = new ButtonComponent(topControls)
.setButtonText('Views')
.setClass('filter-bar__templates-button')
.setTooltip('Saved filter views')
.onClick(() => {
this.toggleViewSelectorDropdown();
});
// Update button state based on active saved view
this.updateViewSelectorButtonState();
// Order based on alignment
if (this.viewsButtonAlignment === 'left') {
makeViewsButton();
makeFilterToggle();
makeSearchInput();
} else {
// Right (default): Filter -> Search -> Views
makeFilterToggle();
makeSearchInput();
makeViewsButton();
}
// Main filter box (now rendered within top-controls for positioning)
this.renderMainFilterBox(topControls);
// Templates dropdown
this.viewSelectorDropdown = topControls.createDiv({
cls: 'filter-bar__view-selector-dropdown filter-bar__view-selector-dropdown--hidden',
});
// Views dropdown; add left modifier when aligning left
const dropdownClass = this.viewsButtonAlignment === 'left'
? 'filter-bar__view-selector-dropdown filter-bar__view-selector-dropdown--hidden filter-bar__view-selector-dropdown--left'
: 'filter-bar__view-selector-dropdown filter-bar__view-selector-dropdown--hidden';
this.viewSelectorDropdown = topControls.createDiv({ cls: dropdownClass });
// Populate the dropdown with saved views
this.renderViewSelectorDropdown();
@ -414,12 +430,12 @@ export class FilterBar extends EventEmitter {
// Create AbortController for clean event management
this.abortController = new AbortController();
const { signal } = this.abortController;
// Click outside handler with passive option for better performance
document.addEventListener('click', (event: MouseEvent) => {
this.handleDocumentClick(event);
}, { signal, passive: true });
// Keyboard escape handler for accessibility
document.addEventListener('keydown', (event: KeyboardEvent) => {
this.handleKeyDown(event);
@ -431,12 +447,12 @@ export class FilterBar extends EventEmitter {
*/
private ignoreNextClickOutside(): void {
this.ignoreNextClick = true;
// Clear any existing timeout
if (this.ignoreClickTimeout) {
window.clearTimeout(this.ignoreClickTimeout);
}
// Reset the flag after a short delay as a safety measure
this.ignoreClickTimeout = window.setTimeout(() => {
this.ignoreNextClick = false;
@ -456,7 +472,7 @@ export class FilterBar extends EventEmitter {
event.preventDefault();
return;
}
// Close view selector dropdown if open
if (this.viewSelectorDropdown && !this.viewSelectorDropdown.classList.contains('filter-bar__view-selector-dropdown--hidden')) {
this.viewSelectorDropdown.classList.add('filter-bar__view-selector-dropdown--hidden');
@ -477,32 +493,32 @@ export class FilterBar extends EventEmitter {
this.ignoreNextClick = false;
return;
}
const target = event.target as HTMLElement;
if (!target) return;
// Check if click is outside the main filter box
if (this.sectionStates.filterBox && this.mainFilterBox && this.container) {
const filterToggleButton = this.container.querySelector('.filter-bar__filter-toggle');
// Check if the click is inside the entire filter bar container
const isInsideFilterBar = this.container.contains(target);
const isFilterToggleButton = filterToggleButton && filterToggleButton.contains(target);
// Only close if clicking completely outside the filter bar, but not on the toggle button
if (!isInsideFilterBar && !isFilterToggleButton) {
this.sectionStates.filterBox = false;
this.updateFilterBoxState();
}
}
// Check if click is outside the view selector dropdown
if (this.viewSelectorDropdown && this.viewSelectorButton?.buttonEl) {
const isDropdownHidden = this.viewSelectorDropdown.classList.contains('filter-bar__view-selector-dropdown--hidden');
// Don't close if clicking on the views button or inside the dropdown
if (!isDropdownHidden &&
!this.viewSelectorButton.buttonEl.contains(target) &&
if (!isDropdownHidden &&
!this.viewSelectorButton.buttonEl.contains(target) &&
!this.viewSelectorDropdown.contains(target)) {
this.viewSelectorDropdown.classList.add('filter-bar__view-selector-dropdown--hidden');
this.viewSelectorButton.buttonEl.classList.remove('filter-bar__templates-button--active');
@ -524,11 +540,11 @@ export class FilterBar extends EventEmitter {
private updateFilterBoxState(): void {
const mainBox = this.container.querySelector('.filter-bar__main-box');
const filterToggle = this.container.querySelector('.filter-bar__filter-toggle');
if (mainBox) {
mainBox.classList.toggle('filter-bar__main-box--collapsed', !this.sectionStates.filterBox);
}
if (filterToggle) {
filterToggle.classList.toggle('filter-bar__filter-toggle--active', this.sectionStates.filterBox);
}
@ -571,17 +587,17 @@ export class FilterBar extends EventEmitter {
const viewItemContainer = savedViewsSection.createDiv({
cls: 'filter-bar__view-item-container'
});
// Make the container draggable
viewItemContainer.draggable = true;
viewItemContainer.setAttribute('data-view-index', index.toString());
// Add drag handle
const dragHandle = viewItemContainer.createDiv({
cls: 'filter-bar__view-drag-handle'
});
setTooltip(dragHandle, 'Drag to reorder views', { placement: 'top' });
const viewItemButton = new ButtonComponent(viewItemContainer)
.setButtonText(view.name)
.setClass('filter-bar__view-item')
@ -615,11 +631,11 @@ export class FilterBar extends EventEmitter {
this.emit('deleteView', view.id);
}
});
// Add drag and drop event handlers
this.dragDropHandler.setupDragAndDrop(viewItemContainer, index);
});
// Add global handlers to ensure drop events work reliably
this.dragDropHandler.setupGlobalHandlers(savedViewsSection);
}
@ -633,14 +649,14 @@ export class FilterBar extends EventEmitter {
// Collapsible header
const header = section.createDiv('filter-bar__section-header');
const titleWrapper = header.createDiv('filter-bar__section-header-main');
titleWrapper.createSpan({
text: 'Filter',
cls: 'filter-bar__section-title'
});
setTooltip(titleWrapper, 'Click to expand/collapse filter conditions', { placement: 'top' });
// Show active saved view name if one is loaded
if (this.activeSavedView) {
titleWrapper.createSpan({
@ -684,21 +700,21 @@ export class FilterBar extends EventEmitter {
console.error('FilterBar: Invalid group object provided to renderFilterGroup');
return;
}
// Ensure children array exists (defensive migration fix)
if (!Array.isArray(group.children)) {
group.children = [];
}
const groupContainer = parent.createDiv('filter-bar__group');
// Group header with conjunction and delete button
const groupHeader = groupContainer.createDiv('filter-bar__group-header');
// Conjunction dropdown
const conjunctionContainer = groupHeader.createDiv('filter-bar__conjunction');
setTooltip(conjunctionContainer, 'Choose whether ALL or ANY of the conditions must match', { placement: 'top' });
new DropdownComponent(conjunctionContainer)
.addOption('and', depth === 0 ? 'All' : 'All')
.addOption('or', depth === 0 ? 'Any' : 'Any')
@ -733,7 +749,7 @@ export class FilterBar extends EventEmitter {
// Action buttons
const actionsContainer = groupContainer.createDiv('filter-bar__group-actions');
new ButtonComponent(actionsContainer)
.setIcon('plus')
.setButtonText('Add filter')
@ -804,7 +820,7 @@ export class FilterBar extends EventEmitter {
condition.value = operatorDef?.requiresValue ? '' : null;
}
}
this.updateUI();
this.emitQueryChange();
});
@ -839,7 +855,7 @@ export class FilterBar extends EventEmitter {
*/
private updateOperatorOptions(dropdown: DropdownComponent, property: FilterProperty): void {
dropdown.selectEl.empty();
const propertyDef = FILTER_PROPERTIES.find(p => p.id === property);
if (!propertyDef) return;
@ -859,7 +875,7 @@ export class FilterBar extends EventEmitter {
const propertyDef = FILTER_PROPERTIES.find(p => p.id === condition.property);
const operatorDef = FILTER_OPERATORS.find(op => op.id === condition.operator);
if (!propertyDef || !operatorDef || !operatorDef.requiresValue) {
return; // No value input needed
}
@ -958,14 +974,14 @@ export class FilterBar extends EventEmitter {
*/
private renderDateInput(container: HTMLElement, condition: FilterCondition): void {
const dateContainer = container.createDiv('filter-date-input-container');
// Main text input for both natural language and date entry
const textInput = new TextComponent(dateContainer)
.setValue(String(condition.value || ''))
.onChange((value) => {
condition.value = value || null;
this.updateDateInputValidation(textInput, value);
// Only emit query change if input is valid or empty
const trimmedValue = (value || '').trim();
if (trimmedValue === '' || isValidDateInput(trimmedValue)) {
@ -973,40 +989,40 @@ export class FilterBar extends EventEmitter {
this.debouncedEmitQueryChange();
}
});
// Set placeholder to guide users
textInput.setPlaceholder('today, 2024-12-25, next week...');
textInput.inputEl.addClass('filter-date-text-input');
setTooltip(textInput.inputEl, 'Enter a date using natural language or ISO format', { placement: 'top' });
// Set initial validation state
this.updateDateInputValidation(textInput, String(condition.value || ''));
// Add a small help button showing natural language examples
const helpButton = dateContainer.createEl('button', {
cls: 'filter-date-help-button',
text: '?'
});
setTooltip(helpButton, 'Show natural language date examples', { placement: 'top' });
helpButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.showNaturalLanguageDateHelp(helpButton);
});
}
/**
* Update date input validation styling based on the current value
*/
private updateDateInputValidation(textInput: TextComponent, value: string): void {
const inputEl = textInput.inputEl;
// Remove all validation classes
inputEl.removeClass('is-valid', 'is-invalid', 'is-empty');
const trimmedValue = value.trim();
if (trimmedValue === '') {
inputEl.addClass('is-empty');
} else if (isValidDateInput(trimmedValue)) {
@ -1015,20 +1031,20 @@ export class FilterBar extends EventEmitter {
inputEl.addClass('is-invalid');
}
}
/**
* Show natural language date help tooltip
*/
private showNaturalLanguageDateHelp(button: HTMLElement): void {
// Remove existing tooltip
document.querySelectorAll('.filter-date-help-tooltip').forEach(el => el.remove());
const tooltip = document.body.createDiv('filter-date-help-tooltip');
// const suggestions = getNaturalLanguageDateSuggestions(); // Not currently used in tooltip
tooltip.createEl('h4', { text: 'Natural Language Dates' });
const examplesList = tooltip.createEl('ul');
// Show the actual available patterns from our simplified implementation
const availablePatterns = [
'today', 'tomorrow', 'yesterday',
@ -1037,24 +1053,24 @@ export class FilterBar extends EventEmitter {
'in 1 week', '2 weeks ago',
'2024-12-25', '2024-12-25T14:30:00'
];
availablePatterns.forEach(example => {
examplesList.createEl('li', { text: example });
});
// Position tooltip near the button
const buttonRect = button.getBoundingClientRect();
tooltip.style.position = 'absolute';
tooltip.style.top = `${buttonRect.bottom + 5}px`;
tooltip.style.left = `${buttonRect.left}px`;
tooltip.style.zIndex = '1000';
// Remove tooltip when clicking elsewhere
const removeTooltip = () => {
tooltip.remove();
document.removeEventListener('click', removeTooltip);
};
// Add delay to prevent immediate removal
setTimeout(() => {
document.addEventListener('click', removeTooltip);
@ -1083,7 +1099,7 @@ export class FilterBar extends EventEmitter {
// Collapsible header
const header = section.createDiv('filter-bar__section-header');
const titleWrapper = header.createDiv('filter-bar__section-header-main');
titleWrapper.createSpan({
text: 'Display & Organization',
@ -1174,7 +1190,7 @@ export class FilterBar extends EventEmitter {
private renderViewOptions(container: HTMLElement): void {
const section = container.createDiv('filter-bar__section');
this.viewOptionsContainer = section.createDiv('filter-bar__view-options-container');
// Initial population of view options
this.populateViewOptions();
}
@ -1200,7 +1216,7 @@ export class FilterBar extends EventEmitter {
this.viewOptionsContainer.addClass('filter-bar__section--hidden');
return;
}
this.viewOptionsContainer.removeClass('filter-bar__section--hidden');
const header = this.viewOptionsContainer.createDiv('filter-bar__section-header');
@ -1218,7 +1234,7 @@ export class FilterBar extends EventEmitter {
options.forEach(option => {
const optionContainer = content.createDiv('filter-bar__view-option');
const label = optionContainer.createEl('label', {
cls: 'filter-bar__view-option-label'
});
@ -1256,7 +1272,7 @@ export class FilterBar extends EventEmitter {
operator: 'contains',
value: ''
};
group.children.push(condition);
this.ignoreNextClickOutside();
this.updateFilterBuilderComplete();
@ -1273,7 +1289,7 @@ export class FilterBar extends EventEmitter {
conjunction: 'and',
children: []
};
group.children.push(newGroup);
this.ignoreNextClickOutside();
this.updateFilterBuilderComplete();
@ -1308,7 +1324,7 @@ export class FilterBar extends EventEmitter {
*/
private updateViewSelectorButtonState(): void {
if (!this.viewSelectorButton?.buttonEl) return;
// Add/remove active state class
if (this.activeSavedView) {
this.viewSelectorButton.buttonEl.classList.add('filter-bar__templates-button--saved-view-active');
@ -1322,10 +1338,10 @@ export class FilterBar extends EventEmitter {
*/
private toggleViewSelectorDropdown(): void {
if (!this.viewSelectorDropdown || !this.viewSelectorButton) return;
const isHidden = this.viewSelectorDropdown.classList.contains('filter-bar__view-selector-dropdown--hidden');
this.viewSelectorDropdown.classList.toggle('filter-bar__view-selector-dropdown--hidden', !isHidden);
// Toggle active state on the button
if (isHidden) {
// Opening dropdown - add active class
@ -1342,7 +1358,7 @@ export class FilterBar extends EventEmitter {
private toggleSection(sectionKey: 'filterBox' | 'filters' | 'display' | 'viewOptions', header: HTMLElement, content: HTMLElement): void {
this.sectionStates[sectionKey] = !this.sectionStates[sectionKey];
const isExpanded = this.sectionStates[sectionKey];
header.classList.toggle('filter-bar__section-header--collapsed', !isExpanded);
content.classList.toggle('filter-bar__section-content--collapsed', !isExpanded);
}
@ -1365,7 +1381,7 @@ export class FilterBar extends EventEmitter {
if (!this.viewOptionsConfig || this.viewOptionsConfig.length === 0) {
return undefined;
}
const options: {[key: string]: boolean} = {};
this.viewOptionsConfig.forEach(option => {
options[option.id] = option.value;
@ -1388,20 +1404,20 @@ export class FilterBar extends EventEmitter {
sortDirection: this.currentQuery.sortDirection || 'asc',
groupKey: this.currentQuery.groupKey || 'none'
};
// Clear the active saved view
this.activeSavedView = null;
// Clear the search input
if (this.searchInput) {
this.searchInput.setValue('');
}
// Update UI and emit change
this.render();
this.updateViewSelectorButtonState();
this.emitQueryChange();
// Close the dropdown
this.toggleViewSelectorDropdown();
}
@ -1415,12 +1431,12 @@ export class FilterBar extends EventEmitter {
this.activeSavedView = view;
this.render();
this.emitQueryChange();
// Emit viewOptions event if they exist
if (view.viewOptions) {
this.emit('loadViewOptions', view.viewOptions);
}
this.toggleViewSelectorDropdown();
this.updateViewSelectorButtonState();
this.isLoadingSavedView = false;
@ -1432,10 +1448,10 @@ export class FilterBar extends EventEmitter {
private updateUI(): void {
// Sync search input with current query
this.syncSearchInput();
// Re-render everything to ensure consistency
this.render();
// Update button state after render
this.updateViewSelectorButtonState();
}
@ -1449,10 +1465,10 @@ export class FilterBar extends EventEmitter {
// Store current search input value and focus state
const currentValue = this.searchInput?.getValue();
const hasFocus = this.searchInput?.inputEl === document.activeElement;
this.filterBuilder.empty();
this.renderFilterGroup(this.filterBuilder, this.currentQuery, 0);
// Restore search input value and focus if needed
if (this.searchInput && currentValue !== undefined) {
this.searchInput.setValue(currentValue);
@ -1460,7 +1476,7 @@ export class FilterBar extends EventEmitter {
this.searchInput.inputEl.focus();
}
}
// Maintain filter button active state
this.updateFilterBoxState();
}
@ -1480,18 +1496,18 @@ export class FilterBar extends EventEmitter {
// Store current search input value and focus state
const currentValue = this.searchInput?.getValue();
const hasFocus = this.searchInput?.inputEl === document.activeElement;
// Update the filter builder section completely
this.filterBuilder.empty();
this.renderFilterGroup(this.filterBuilder, this.currentQuery, 0);
// Update display section to ensure sort/group controls are in sync
const displaySection = this.container.querySelector('.filter-bar__display-section');
if (displaySection) {
displaySection.empty();
this.renderDisplaySection(displaySection as HTMLElement);
}
// Restore search input value and focus if needed
if (this.searchInput && currentValue !== undefined) {
this.searchInput.setValue(currentValue);
@ -1499,7 +1515,7 @@ export class FilterBar extends EventEmitter {
this.searchInput.inputEl.focus();
}
}
// Maintain filter button active state
this.updateFilterBoxState();
}
@ -1515,21 +1531,21 @@ export class FilterBar extends EventEmitter {
*/
private syncSearchInput(): void {
if (!this.searchInput || this.isUserTyping) return;
// Defensive check: ensure currentQuery has children array
if (!this.currentQuery || !Array.isArray(this.currentQuery.children)) {
this.searchInput.setValue('');
return;
}
// Find search condition in current query
const searchCondition = this.currentQuery.children.find(child =>
child.type === 'condition' &&
child.property === 'title' &&
const searchCondition = this.currentQuery.children.find(child =>
child.type === 'condition' &&
child.property === 'title' &&
child.operator === 'contains' &&
child.id.startsWith('search_')
) as FilterCondition | undefined;
// Update search input value only if user is not actively typing
this.searchInput.setValue(String(searchCondition?.value || ''));
}
@ -1545,11 +1561,11 @@ export class FilterBar extends EventEmitter {
// Re-render the filter section to remove the view name display
this.updateFilterBuilder();
}
// Always emit for sort/group changes and structural operations
this.emitQueryChangeIfComplete();
}
/**
* Check if the current query is complete and meaningful, then emit if so
*/
@ -1558,7 +1574,7 @@ export class FilterBar extends EventEmitter {
this.emit('queryChange', FilterUtils.deepCloneFilterQuery(this.currentQuery));
}
}
/**
* Check if a query is meaningful (has complete conditions or no conditions)
* Incomplete conditions (missing required values) should not trigger filtering
@ -1568,11 +1584,11 @@ export class FilterBar extends EventEmitter {
if (query.children.length === 0) {
return true;
}
// Check if query has at least one complete condition or group
return this.hasCompleteConditions(query);
}
/**
* Recursively check if a group has any complete conditions
*/
@ -1627,13 +1643,13 @@ export class FilterBar extends EventEmitter {
this.container.empty();
}
this.removeAllListeners();
// Clean up event listeners and timeout
if (this.abortController) {
this.abortController.abort();
this.abortController = undefined;
}
if (this.ignoreClickTimeout) {
window.clearTimeout(this.ignoreClickTimeout);
this.ignoreClickTimeout = undefined;
@ -1647,7 +1663,7 @@ export class FilterBar extends EventEmitter {
this.abortController.abort();
this.abortController = undefined;
}
if (this.ignoreClickTimeout) {
window.clearTimeout(this.ignoreClickTimeout);
this.ignoreClickTimeout = undefined;

View file

@ -301,7 +301,8 @@ export class AgendaView extends ItemView {
this.app,
filterBarContainer,
this.currentQuery,
filterOptions
filterOptions,
this.plugin.settings.viewsButtonAlignment || 'right'
);
// Get saved views for the FilterBar

View file

@ -206,7 +206,8 @@ export class KanbanView extends ItemView {
this.app,
filterBarContainer,
this.currentQuery,
filterOptions
filterOptions,
this.plugin.settings.viewsButtonAlignment || 'right'
);
// Get saved views for the FilterBar

View file

@ -263,7 +263,8 @@ export class TaskListView extends ItemView {
this.app,
filterBarContainer,
this.currentQuery,
filterOptions
filterOptions,
this.plugin.settings.viewsButtonAlignment || 'right'
);
// Get saved views for the FilterBar

View file

@ -233,7 +233,8 @@
.tasknotes-plugin .filter-bar__view-selector-dropdown {
position: absolute;
top: 100%;
right: 0; /* Align to the right of the container */
right: 0; /* Default align to right of the container */
left: auto;
min-width: 240px;
max-width: 320px;
z-index: 1000; /* Ensure it's on top */
@ -249,6 +250,12 @@
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Left-aligned variant for Views-left configuration */
.tasknotes-plugin .filter-bar__view-selector-dropdown.filter-bar__view-selector-dropdown--left {
left: 0;
right: auto;
}
.tasknotes-plugin .filter-bar__view-selector-dropdown--hidden {
opacity: 0;
transform: translateY(-10px) scale(0.95);

View file

@ -45,7 +45,7 @@ class MockVaultFileSystem {
// File operations
create(path: string, content: string): MockFile {
const normalizedPath = this.normalizePath(path);
if (this.files.has(normalizedPath)) {
throw new Error(`File already exists: ${normalizedPath}`);
}
@ -89,7 +89,7 @@ class MockVaultFileSystem {
rename(oldPath: string, newPath: string): void {
const oldNormalized = this.normalizePath(oldPath);
const newNormalized = this.normalizePath(newPath);
const file = this.files.get(oldNormalized);
if (file) {
this.files.delete(oldNormalized);
@ -126,7 +126,7 @@ class MockVaultFileSystem {
// Folder operations
ensureFolderExists(path: string): void {
if (!path) return;
const normalizedPath = this.normalizePath(path);
if (this.folders.has(normalizedPath)) return;
@ -196,12 +196,12 @@ class MockVaultFileSystem {
// Global mock file system instance
const mockFileSystem = new MockVaultFileSystem();
// TFile mock class
// TFile mock class
export class TFile {
public path: string;
public vault: any = null;
public parent: any = null;
constructor(path?: string) {
this.path = path || '';
}
@ -443,19 +443,19 @@ export class Workspace {
}
}
// App mock class
// App mock class
export class App {
vault = new Vault();
metadataCache = new MetadataCache();
fileManager = new FileManager();
workspace = new Workspace();
// Required Obsidian App properties
keymap = new Keymap();
scope = new Scope();
lastEvent: any = null;
loadLocalStorage = jest.fn((key: string) => {
try {
return JSON.parse(localStorage.getItem(`obsidian-app-${key}`) || 'null');
@ -463,7 +463,7 @@ export class App {
return null;
}
});
saveLocalStorage = jest.fn((key: string, data: unknown | null) => {
if (data === null) {
localStorage.removeItem(`obsidian-app-${key}`);
@ -477,11 +477,11 @@ export class App {
mockFileSystem.on('create', (file: MockFile) => {
this.metadataCache.setCache(file.path, this.parseFileMetadata(file.content));
});
mockFileSystem.on('modify', (file: MockFile) => {
this.metadataCache.setCache(file.path, this.parseFileMetadata(file.content));
});
mockFileSystem.on('delete', (file: MockFile) => {
this.metadataCache.deleteCache(file.path);
});
@ -517,15 +517,15 @@ export class Plugin {
async onload(): Promise<void> {}
onunload(): void {}
addCommand(command: any): void {}
addRibbonIcon(icon: string, title: string, callback: () => void): void {}
addSettingTab(tab: any): void {}
loadData(): Promise<any> {
return Promise.resolve({});
}
saveData(data: any): Promise<void> {
return Promise.resolve();
}
@ -534,12 +534,12 @@ export class Plugin {
// Component mock class
export class Component {
private children: Component[] = [];
addChild<T extends Component>(component: T): T {
this.children.push(component);
return component;
}
removeChild<T extends Component>(component: T): T {
const index = this.children.indexOf(component);
if (index !== -1) {
@ -547,7 +547,7 @@ export class Component {
}
return component;
}
onload(): void {}
onunload(): void {}
}
@ -567,7 +567,7 @@ export class Modal extends Component {
this.contentEl = document.createElement('div');
this.modalEl = document.createElement('div');
this.containerEl = this.contentEl; // For compatibility with older APIs
// Add Obsidian DOM methods to contentEl
this.contentEl.addClass = function(...classes: string[]) {
this.classList.add(...classes);
@ -606,7 +606,7 @@ export class Modal extends Component {
}
}
this.appendChild(el);
// Add the same DOM methods to the created element
if (!el.addClass) {
el.addClass = this.addClass;
@ -615,7 +615,7 @@ export class Modal extends Component {
el.createDiv = this.createDiv;
el.empty = this.empty;
}
return el;
};
this.contentEl.createDiv = function(attrs?: any): HTMLDivElement {
@ -625,7 +625,7 @@ export class Modal extends Component {
this.innerHTML = '';
return this;
};
// Copy methods to containerEl as well
this.containerEl.addClass = this.contentEl.addClass;
this.containerEl.removeClass = this.contentEl.removeClass;
@ -637,11 +637,11 @@ export class Modal extends Component {
open(): void {
this.onOpen();
}
close(): void {
this.onClose();
}
onOpen(): void {}
onClose(): void {}
}
@ -698,6 +698,63 @@ export class ItemView extends Component {
async onClose(): Promise<void> {}
}
// --- UI Components used by FilterBar ---
export class ButtonComponent {
public buttonEl: HTMLButtonElement;
constructor(containerEl: HTMLElement) {
this.buttonEl = document.createElement('button');
containerEl.appendChild(this.buttonEl);
}
setIcon(_icon: string) { return this; }
setButtonText(text: string) { this.buttonEl.textContent = text; return this; }
setClass(cls: string) { this.buttonEl.classList.add(cls); return this; }
setTooltip(_tooltip: string) { return this; }
setCta() { return this; }
onClick(callback: () => void) { this.buttonEl.addEventListener('click', callback); return this; }
}
export class TextComponent {
public inputEl: HTMLInputElement;
private _onChange: ((value: string) => void) | null = null;
constructor(containerEl: HTMLElement) {
this.inputEl = document.createElement('input');
this.inputEl.type = 'text';
containerEl.appendChild(this.inputEl);
this.inputEl.addEventListener('input', () => { this._onChange?.(this.inputEl.value); });
}
setPlaceholder(placeholder: string) { this.inputEl.placeholder = placeholder; return this; }
onChange(cb: (value: string) => void) { this._onChange = cb; return this; }
setValue(value: string) { this.inputEl.value = value; return this; }
getValue() { return this.inputEl.value; }
}
export class DropdownComponent {
public selectEl: HTMLSelectElement;
private _onChange: ((value: string) => void) | null = null;
constructor(containerEl: HTMLElement) {
this.selectEl = document.createElement('select');
containerEl.appendChild(this.selectEl);
this.selectEl.addEventListener('change', () => { this._onChange?.(this.selectEl.value); });
}
addOptions(options: Record<string, string>) {
Object.entries(options).forEach(([value, label]) => {
const opt = document.createElement('option');
opt.value = value; opt.textContent = label;
this.selectEl.appendChild(opt);
});
return this;
}
addOption(value: string, label: string) {
const opt = document.createElement('option');
opt.value = value; opt.textContent = label;
this.selectEl.appendChild(opt);
return this;
}
setValue(value: string) { this.selectEl.value = value; return this; }
onChange(cb: (value: string) => void) { this._onChange = cb; return this; }
}
// Setting mock class
export class Setting {
containerEl: HTMLElement;
@ -705,26 +762,26 @@ export class Setting {
descEl: HTMLElement;
controlEl: HTMLElement;
settingEl: HTMLElement;
constructor(containerEl: HTMLElement) {
this.containerEl = containerEl;
this.settingEl = document.createElement('div');
this.nameEl = document.createElement('div');
this.descEl = document.createElement('div');
this.descEl = document.createElement('div');
this.controlEl = document.createElement('div');
this.containerEl.appendChild(this.settingEl);
}
setName(name: string): Setting {
this.nameEl.textContent = name;
return this;
}
setDesc(desc: string): Setting {
this.descEl.textContent = desc;
return this;
}
setHeading(): Setting {
if (this.settingEl.addClass) {
this.settingEl.addClass('setting-item-heading');
@ -733,7 +790,7 @@ export class Setting {
}
return this;
}
addText(callback: (text: any) => void): Setting {
const mockText = {
inputEl: document.createElement('input'),
@ -744,7 +801,7 @@ export class Setting {
callback(mockText);
return this;
}
addToggle(callback: (toggle: any) => void): Setting {
const mockToggle = {
toggleEl: document.createElement('div'),
@ -754,7 +811,7 @@ export class Setting {
callback(mockToggle);
return this;
}
addDropdown(callback: (dropdown: any) => void): Setting {
const mockDropdown = {
selectEl: document.createElement('select'),
@ -765,7 +822,7 @@ export class Setting {
callback(mockDropdown);
return this;
}
addButton(callback: (button: any) => void): Setting {
const mockButton = {
buttonEl: document.createElement('button'),
@ -842,7 +899,7 @@ export function parseLinktext(linktext: string): { path: string; subpath: string
subpath: linktext.slice(hashIndex + 1).trim()
};
}
// Simple path: [[path]]
return {
path: linktext.trim(),
@ -881,11 +938,11 @@ export class Keymap {
// Scope mock class
export class Scope {
keys: any[] = [];
constructor(parent?: Scope) {
// Mock constructor
}
register = jest.fn();
unregister = jest.fn();
}
@ -912,31 +969,41 @@ export const MockObsidian = {
reset: () => {
mockFileSystem.reset();
},
getFileSystem: () => mockFileSystem,
// Helper to create test files
createTestFile: (path: string, content: string) => {
return mockFileSystem.create(path, content);
},
// Helper to get test files
getTestFiles: () => {
return mockFileSystem.getFiles();
},
// Helper to create mock app instance
createMockApp: () => {
return new App();
},
// Export class constructors for testing
// Helpers exposed for tests to use
createTestFile: (path: string, content: string) => mockFileSystem.create(path, content),
getTestFiles: () => mockFileSystem.getFiles(),
createMockApp: () => new App(),
// Expose certain constructors/mocks for easy overriding in tests
Menu,
Notice,
setIcon,
setTooltip,
};
// Simple debounce mock: return the original function for test determinism
export function debounce<T extends (...args: any[]) => any>(fn: T, _wait?: number): T {
return fn;
}
// Helper to create test files
export function createTestFile(path: string, content: string) {
return mockFileSystem.create(path, content);
}
// Helper to get test files
export function getTestFiles() {
return mockFileSystem.getFiles();
}
// Helper to create mock app instance
export function createMockApp() {
return new App();
}
// Default export for compatibility
export default {
TFile,
@ -967,4 +1034,11 @@ export default {
setTooltip,
parseLinktext,
MockObsidian,
debounce,
ButtonComponent,
TextComponent,
DropdownComponent,
createTestFile,
getTestFiles,
createMockApp,
};

View file

@ -17,9 +17,12 @@ function createMockElement(tagName: string = 'div'): any {
// Mock Obsidian's createEl method
element.createEl = function(this: any, tagName: string, options: any = {}, callback?: (el: any) => void): any {
const child = createMockElement(tagName);
// Handle options object
if (options) {
if (typeof options === 'string') {
// Support Obsidian API shorthand: createDiv('class-name')
child.className = options;
} else if (options) {
// Handle class names
if (options.cls) {
if (typeof options.cls === 'string') {
@ -28,40 +31,40 @@ function createMockElement(tagName: string = 'div'): any {
child.className = options.cls.join(' ');
}
}
// Handle text content
if (options.text !== undefined) {
child.textContent = options.text;
}
// Handle HTML content
if (options.html !== undefined) {
child.innerHTML = options.html;
}
// Handle attributes
if (options.attr) {
Object.entries(options.attr).forEach(([key, value]) => {
child.setAttribute(key, String(value));
});
}
// Handle other properties (like type for input elements)
Object.keys(options).forEach(key => {
if (!['cls', 'text', 'html', 'attr'].includes(key)) {
(child as any)[key] = options[key];
(child as any)[key] = options[key as keyof typeof options];
}
});
}
// Append to parent
this.appendChild(child);
// Call callback if provided
if (callback) {
callback(child);
}
return child;
};
@ -102,6 +105,12 @@ function createMockElement(tagName: string = 'div'): any {
}
return this;
};
// Add Obsidian-specific helpers
(element as any).empty = function(this: any): any {
while (this.firstChild) this.removeChild(this.firstChild);
return this;
};
return element;
}
@ -139,7 +148,7 @@ if (!global.IntersectionObserver) {
root = null;
rootMargin = '';
thresholds = [];
constructor() {}
observe() {}
unobserve() {}
@ -153,7 +162,7 @@ if (!global.URL) {
global.URL = class URL {
constructor(public href: string, public base?: string) {}
toString() { return this.href; }
static createObjectURL() { return ''; }
static revokeObjectURL() {}
} as any;
@ -196,20 +205,20 @@ jest.setTimeout(10000);
export const TestUtils = {
// Wait for next tick
nextTick: () => new Promise(resolve => setTimeout(resolve, 0)),
// Wait for specific duration
wait: (ms: number) => new Promise(resolve => setTimeout(resolve, ms)),
// Create a mock date that's consistent across tests
createMockDate: (dateString: string) => new Date(dateString),
// Mock current date for consistent testing
mockCurrentDate: (dateString: string) => {
const mockDate = new Date(dateString);
jest.spyOn(global.Date, 'now').mockReturnValue(mockDate.getTime());
return mockDate;
},
// Restore date mocking
restoreDate: () => {
jest.restoreAllMocks();

View file

@ -0,0 +1,8 @@
import { DEFAULT_SETTINGS } from '../../../src/settings/settings';
describe('Settings defaults', () => {
test('viewsButtonAlignment defaults to right', () => {
expect(DEFAULT_SETTINGS.viewsButtonAlignment).toBe('right');
});
});

View file

@ -0,0 +1,65 @@
import { App } from '../../__mocks__/obsidian';
import { FilterBar } from '../../../src/ui/FilterBar';
describe('FilterBar top controls alignment', () => {
const emptyQuery: any = { type: 'group', id: 'g', conjunction: 'and', children: [] };
const emptyOptions: any = { statuses: [], priorities: [], tags: [], contexts: [], projects: [] };
function setupContainer() {
document.body.innerHTML = '';
const container = document.createElement('div');
document.body.appendChild(container);
return container;
}
test('right alignment: Filter -> Search -> Views ordering', () => {
const container = setupContainer();
const fb = new FilterBar(new App(), container, emptyQuery, emptyOptions, 'right');
const row = container.querySelector('.filter-bar__top-controls') as HTMLElement;
expect(row).toBeTruthy();
const filterBtn = row.querySelector('.filter-bar__filter-toggle') as HTMLElement;
const searchInput = row.querySelector('.filter-bar__search-input') as HTMLElement;
const viewsBtn = row.querySelector('.filter-bar__templates-button') as HTMLElement;
expect(filterBtn).toBeTruthy();
expect(searchInput).toBeTruthy();
expect(viewsBtn).toBeTruthy();
// Relative order: filter before search before views
expect(filterBtn.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(searchInput.compareDocumentPosition(viewsBtn) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
test('left alignment: Views -> Filter -> Search ordering', () => {
const container = setupContainer();
const fb = new FilterBar(new App(), container, emptyQuery, emptyOptions, 'left');
const row = container.querySelector('.filter-bar__top-controls') as HTMLElement;
expect(row).toBeTruthy();
const viewsBtn = row.querySelector('.filter-bar__templates-button') as HTMLElement;
const filterBtn = row.querySelector('.filter-bar__filter-toggle') as HTMLElement;
const searchInput = row.querySelector('.filter-bar__search-input') as HTMLElement;
expect(viewsBtn).toBeTruthy();
expect(filterBtn).toBeTruthy();
expect(searchInput).toBeTruthy();
// Relative order: views before filter before search
expect(viewsBtn.compareDocumentPosition(filterBtn) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(filterBtn.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
test('dropdown alignment class toggles', () => {
const container = setupContainer();
new FilterBar(new App(), container, emptyQuery, emptyOptions, 'left');
const ddLeft = container.querySelector('.filter-bar__view-selector-dropdown') as HTMLElement;
expect(ddLeft.className).toMatch(/filter-bar__view-selector-dropdown--left/);
const container2 = setupContainer();
new FilterBar(new App(), container2, emptyQuery, emptyOptions, 'right');
const ddRight = container2.querySelector('.filter-bar__view-selector-dropdown') as HTMLElement;
expect(ddRight.className).not.toMatch(/filter-bar__view-selector-dropdown--left/);
});
});