mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
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:
parent
9f27be5f05
commit
a2aaab906c
18 changed files with 1142 additions and 871 deletions
BIN
docs/assets/saved_views_button_collapse_all.gif
Normal file
BIN
docs/assets/saved_views_button_collapse_all.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
BIN
docs/assets/saved_views_button_no_expand_collapse_all.gif
Normal file
BIN
docs/assets/saved_views_button_no_expand_collapse_all.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 323 KiB |
|
|
@ -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):
|
||||
|
||||

|
||||
|
||||
Left position:
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
36
src/types/chrono-node.d.ts
vendored
Normal 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;
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
8
tests/unit/settings/SettingsDefaults.test.ts
Normal file
8
tests/unit/settings/SettingsDefaults.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
||||
65
tests/unit/ui/FilterBarAlignment.test.ts
Normal file
65
tests/unit/ui/FilterBarAlignment.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in a new issue