mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
* feat: implement multi-field user properties for dynamic filtering - Add userFields[] data model with id, key, displayName, type - Migrate legacy userField to userFields array on load - Extend FilterOptions with userProperties from settings - Update FilterBar to include user properties in dropdown - Add evaluation logic for user:* property conditions - Create Settings UI for managing multiple user fields - Add comprehensive unit tests for all components - Support Text, Number, Date, Boolean, List field types * fix(settings/user-fields): widen inputs and align layout with Priority section - Use grid tailored for user-fields (1fr 1fr 160px 80px) - Make Property Name and Display Name inputs flex to full width - Standardize type dropdown width; stack neatly on mobile - Follow existing settings-view patterns for consistency * feat: add bracket/quote-aware CSV splitting and missing numeric operators - Add src/utils/stringSplit.ts with splitListPreservingLinksAndQuotes() - Single-pass O(n) algorithm preserves commas inside [[...]] and quotes - Handles wikilink aliases [[path|alias]] correctly - Trims tokens and ignores empty values - Integrate splitter in FilterService.normalizeUserListValue for string inputs - Add missing numeric operators: is-greater-than-or-equal, is-less-than-or-equal - Update FilterOperator type and FILTER_OPERATORS labels - Add support for timeEstimate and user number fields - Implement comparison logic in FilterUtils - Fix instant user field availability in FilterBar after settings changes - Invalidate filter options cache in main.saveSettings() - Push fresh options to open views in settings.ts - Add comprehensive unit tests for string splitting and filter normalization - Fix TypeScript compilation issue with new operator validation * docs: add comprehensive user fields documentation and examples - Add User Fields section to Advanced Settings with setup guide - Document all 5 field types (text, number, date, boolean, list) with examples - Explain smart list filtering that preserves wikilinks and quotes - Document numeric intelligence for mixed text-number formats - Add new comparison operators (equal or greater/less than) to filtering docs - Include practical filtering examples for custom user fields - Add technical implementation guide for developers - Include demonstration GIFs showing list and numeric field filtering - Update main settings overview to reference user fields feature * feat(filter): group by custom user fields; add dynamic group dropdown - Support user: group keys (text/number/boolean/date/list) - Sorting: date asc, boolean true→false, number desc, others alphabetic - FilterBar includes user properties from FilterOptions.userProperties - test: add coverage for boolean/list/date/unknown and FilterBar options * feat(filtering): add sorting by user fields and wire Sort dropdown; fix grouped user-field header order to follow sort direction; emit immediate query change for sort/group; list empty values sort last * docs(filtering): add docs for sorting/grouping by custom user fields and embed demo GIF --------- Co-authored-by: Callum Alpass <callumalpass@gmail.com>
This commit is contained in:
parent
b383904f5f
commit
ad6d8ceeda
12 changed files with 857 additions and 109 deletions
BIN
docs/assets/sort-by-custom-user-fields.gif
Normal file
BIN
docs/assets/sort-by-custom-user-fields.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
|
|
@ -249,6 +249,11 @@ Available sort options:
|
|||
- `priority` - Priority level (by configured weight)
|
||||
- `title` - Alphabetical (A-Z)
|
||||
- `createdDate` - Date created (newest first)
|
||||
- User-defined fields (from Settings → Advanced → User Fields). Sorting is type‑aware (number/date/boolean/list). For list fields, empty or missing values sort last.
|
||||
|
||||
When Group by matches the selected Sort user field, group header order follows the sort direction (asc/desc).
|
||||
|
||||

|
||||
|
||||
**Fallback Sorting**: When the primary sort criteria are equal, tasks are sorted by: scheduled → due → priority → title
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ This allows filtering like "Priority greater than 1" to match "2-Medium" and "3-
|
|||
|
||||
### Instant Availability
|
||||
|
||||
User fields become available in filter dropdowns immediately after configuration - no plugin reload required. The fields appear in all TaskNotes views (Task List, Agenda, Kanban, Advanced Calendar) as soon as you save the settings.
|
||||
User fields become available in filter, sorting, and grouping menus immediately after configuration—no plugin reload required. The fields appear in all TaskNotes views (Task List, Agenda, Kanban, Advanced Calendar) as soon as you save the settings.
|
||||
|
||||
### Technical Implementation
|
||||
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ Available sort criteria:
|
|||
- `scheduled` - Scheduled date
|
||||
- `priority` - Priority level (by weight)
|
||||
- `title` - Alphabetical
|
||||
- User fields (from Settings → Advanced → User Fields), shown by their Display Name
|
||||
|
||||
When primary sort criteria are equal, tasks are sorted by: scheduled → due → priority → title
|
||||
|
||||
|
|
@ -118,6 +119,8 @@ Available grouping options:
|
|||
- `context` - By first context
|
||||
- `project` - By project (tasks can appear in multiple groups)
|
||||
|
||||
Note: When Group by equals the Sort user field, group headers follow the selected sort direction.
|
||||
|
||||
### Project Group Headers
|
||||
|
||||
When grouping tasks by project, the project group headers are interactive:
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ export class FilterService extends EventEmitter {
|
|||
private filterOptionsComputeCount = 0;
|
||||
private filterOptionsCacheHits = 0;
|
||||
|
||||
private currentSortKey?: TaskSortKey;
|
||||
private currentSortDirection?: SortDirection;
|
||||
constructor(
|
||||
cacheManager: MinimalNativeCache,
|
||||
statusManager: StatusManager,
|
||||
|
|
@ -74,10 +76,14 @@ export class FilterService extends EventEmitter {
|
|||
// Apply full filter query to the reduced candidate set
|
||||
const filteredTasks = candidateTasks.filter(task => this.evaluateFilterNode(query, task, targetDate));
|
||||
|
||||
// Sort the filtered results
|
||||
// Sort the filtered results (flat sort)
|
||||
const sortedTasks = this.sortTasks(filteredTasks, query.sortKey || 'due', query.sortDirection || 'asc');
|
||||
|
||||
// Group the sorted results
|
||||
// Expose current sort to group ordering logic (used when groupKey === sortKey)
|
||||
this.currentSortKey = (query.sortKey || 'due');
|
||||
this.currentSortDirection = (query.sortDirection || 'asc');
|
||||
|
||||
// Group the results; group order handled inside sortGroups
|
||||
return this.groupTasks(sortedTasks, query.groupKey || 'none', targetDate);
|
||||
} catch (error) {
|
||||
if (error instanceof FilterValidationError || error instanceof FilterEvaluationError) {
|
||||
|
|
@ -792,21 +798,25 @@ export class FilterService extends EventEmitter {
|
|||
let comparison = 0;
|
||||
|
||||
// Primary sort criteria
|
||||
switch (sortKey) {
|
||||
case 'due':
|
||||
comparison = this.compareDates(a.due, b.due);
|
||||
break;
|
||||
case 'scheduled':
|
||||
comparison = this.compareDates(a.scheduled, b.scheduled);
|
||||
break;
|
||||
case 'priority':
|
||||
comparison = this.comparePriorities(a.priority, b.priority);
|
||||
break;
|
||||
case 'title':
|
||||
comparison = a.title.localeCompare(b.title);
|
||||
break;
|
||||
case 'dateCreated':
|
||||
comparison = this.compareDates(a.dateCreated, b.dateCreated);
|
||||
if (typeof sortKey === 'string' && sortKey.startsWith('user:')) {
|
||||
comparison = this.compareByUserField(a, b, sortKey as `user:${string}`);
|
||||
} else {
|
||||
switch (sortKey) {
|
||||
case 'due':
|
||||
comparison = this.compareDates(a.due, b.due);
|
||||
break;
|
||||
case 'scheduled':
|
||||
comparison = this.compareDates(a.scheduled, b.scheduled);
|
||||
break;
|
||||
case 'priority':
|
||||
comparison = this.comparePriorities(a.priority, b.priority);
|
||||
break;
|
||||
case 'title':
|
||||
comparison = a.title.localeCompare(b.title);
|
||||
break;
|
||||
case 'dateCreated':
|
||||
comparison = this.compareDates(a.dateCreated, b.dateCreated);
|
||||
}
|
||||
}
|
||||
|
||||
// If primary criteria are equal, apply fallback sorting
|
||||
|
|
@ -893,6 +903,96 @@ export class FilterService extends EventEmitter {
|
|||
return 0;
|
||||
}
|
||||
|
||||
|
||||
/** Compare by dynamic user field for sorting */
|
||||
private compareByUserField(a: TaskInfo, b: TaskInfo, sortKey: `user:${string}`): number {
|
||||
const fieldId = sortKey.slice(5);
|
||||
const userFields = this.plugin?.settings?.userFields || [];
|
||||
const field = userFields.find((f: any) => (f.id || f.key) === fieldId);
|
||||
if (!field) return 0;
|
||||
|
||||
const getRaw = (t: TaskInfo) => {
|
||||
try {
|
||||
const app = this.cacheManager.getApp();
|
||||
const file = app.vault.getAbstractFileByPath(t.path);
|
||||
const fm = file ? app.metadataCache.getFileCache(file as any)?.frontmatter : undefined;
|
||||
return fm ? fm[field.key] : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const rawA = getRaw(a);
|
||||
const rawB = getRaw(b);
|
||||
|
||||
switch (field.type) {
|
||||
case 'number': {
|
||||
const numA = typeof rawA === 'number' ? rawA : (rawA != null ? parseFloat(String(rawA)) : NaN);
|
||||
const numB = typeof rawB === 'number' ? rawB : (rawB != null ? parseFloat(String(rawB)) : NaN);
|
||||
const isNumA = !isNaN(numA);
|
||||
const isNumB = !isNaN(numB);
|
||||
if (isNumA && isNumB) return numA - numB;
|
||||
if (isNumA && !isNumB) return -1;
|
||||
if (!isNumA && isNumB) return 1;
|
||||
return 0;
|
||||
}
|
||||
case 'boolean': {
|
||||
const toBool = (v: any): boolean | undefined => {
|
||||
if (typeof v === 'boolean') return v;
|
||||
if (v == null) return undefined;
|
||||
const s = String(v).trim().toLowerCase();
|
||||
if (s === 'true') return true;
|
||||
if (s === 'false') return false;
|
||||
return undefined;
|
||||
};
|
||||
const bA = toBool(rawA);
|
||||
const bB = toBool(rawB);
|
||||
if (bA === bB) return 0;
|
||||
if (bA === true) return -1;
|
||||
if (bB === true) return 1;
|
||||
if (bA === false) return -1;
|
||||
if (bB === false) return 1;
|
||||
return 0;
|
||||
}
|
||||
case 'date': {
|
||||
const tA = rawA ? Date.parse(String(rawA)) : NaN;
|
||||
const tB = rawB ? Date.parse(String(rawB)) : NaN;
|
||||
const isValidA = !isNaN(tA);
|
||||
const isValidB = !isNaN(tB);
|
||||
if (isValidA && isValidB) return tA - tB;
|
||||
if (isValidA && !isValidB) return -1;
|
||||
if (!isValidA && isValidB) return 1;
|
||||
return 0;
|
||||
}
|
||||
case 'list': {
|
||||
const toFirst = (v: any): string | undefined => {
|
||||
if (Array.isArray(v)) {
|
||||
const tokens = this.normalizeUserListValue(v);
|
||||
return tokens[0];
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
if (v.trim().length === 0) return '';
|
||||
const tokens = this.normalizeUserListValue(v);
|
||||
return tokens[0];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const sA = toFirst(rawA);
|
||||
const sB = toFirst(rawB);
|
||||
if ((sA == null || sA === '') && (sB == null || sB === '')) return 0;
|
||||
if (sA == null || sA === '') return 1; // empty/missing last
|
||||
if (sB == null || sB === '') return -1;
|
||||
return sA.localeCompare(sB);
|
||||
}
|
||||
case 'text':
|
||||
default: {
|
||||
const sA = rawA != null ? String(rawA) : '';
|
||||
const sB = rawB != null ? String(rawB) : '';
|
||||
return sA.localeCompare(sB);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group sorted tasks by specified criteria
|
||||
*/
|
||||
|
|
@ -927,27 +1027,32 @@ export class FilterService extends EventEmitter {
|
|||
// For all other grouping types, use single group assignment
|
||||
let groupValue: string;
|
||||
|
||||
switch (groupKey) {
|
||||
case 'status':
|
||||
groupValue = task.status || 'no-status';
|
||||
break;
|
||||
case 'priority':
|
||||
groupValue = task.priority || 'unknown';
|
||||
break;
|
||||
case 'context':
|
||||
// For multiple contexts, put task in first context or 'none'
|
||||
groupValue = (task.contexts && task.contexts.length > 0)
|
||||
? task.contexts[0]
|
||||
: 'none';
|
||||
break;
|
||||
case 'due':
|
||||
groupValue = this.getDueDateGroup(task, targetDate);
|
||||
break;
|
||||
case 'scheduled':
|
||||
groupValue = this.getScheduledDateGroup(task, targetDate);
|
||||
break;
|
||||
default:
|
||||
groupValue = 'unknown';
|
||||
// Handle dynamic user field grouping
|
||||
if (typeof groupKey === 'string' && groupKey.startsWith('user:')) {
|
||||
groupValue = this.getUserFieldGroupValue(task, groupKey);
|
||||
} else {
|
||||
switch (groupKey) {
|
||||
case 'status':
|
||||
groupValue = task.status || 'no-status';
|
||||
break;
|
||||
case 'priority':
|
||||
groupValue = task.priority || 'unknown';
|
||||
break;
|
||||
case 'context':
|
||||
// For multiple contexts, put task in first context or 'none'
|
||||
groupValue = (task.contexts && task.contexts.length > 0)
|
||||
? task.contexts[0]
|
||||
: 'none';
|
||||
break;
|
||||
case 'due':
|
||||
groupValue = this.getDueDateGroup(task, targetDate);
|
||||
break;
|
||||
case 'scheduled':
|
||||
groupValue = this.getScheduledDateGroup(task, targetDate);
|
||||
break;
|
||||
default:
|
||||
groupValue = 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
if (!groups.has(groupValue)) {
|
||||
|
|
@ -960,6 +1065,65 @@ export class FilterService extends EventEmitter {
|
|||
return this.sortGroups(groups, groupKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Extract group value for a dynamic user field group key (user:<id>)
|
||||
*/
|
||||
private getUserFieldGroupValue(task: TaskInfo, groupKey: string): string {
|
||||
const fieldId = groupKey.slice(5);
|
||||
const userFields = this.plugin?.settings?.userFields || [];
|
||||
const field = userFields.find((f: any) => (f.id || f.key) === fieldId);
|
||||
if (!field) return 'unknown-field';
|
||||
|
||||
try {
|
||||
const app = this.cacheManager.getApp();
|
||||
const file = app.vault.getAbstractFileByPath(task.path);
|
||||
if (!file) return 'no-value';
|
||||
const fm = app.metadataCache.getFileCache(file as any)?.frontmatter;
|
||||
const raw = fm ? fm[field.key] : undefined;
|
||||
|
||||
switch (field.type) {
|
||||
case 'boolean': {
|
||||
if (typeof raw === 'boolean') return raw ? 'true' : 'false';
|
||||
if (raw == null) return 'no-value';
|
||||
const s = String(raw).trim().toLowerCase();
|
||||
if (s === 'true') return 'true';
|
||||
if (s === 'false') return 'false';
|
||||
return 'no-value';
|
||||
}
|
||||
case 'number': {
|
||||
if (typeof raw === 'number') return String(raw);
|
||||
if (typeof raw === 'string') {
|
||||
const match = raw.match(/^(\d+(?:\.\d+)?)/);
|
||||
return match ? match[1] : 'non-numeric';
|
||||
}
|
||||
return 'no-value';
|
||||
}
|
||||
case 'date':
|
||||
return raw ? String(raw) : 'no-date';
|
||||
case 'list': {
|
||||
if (Array.isArray(raw)) {
|
||||
const tokens = this.normalizeUserListValue(raw);
|
||||
return tokens.length > 0 ? tokens[0] : 'empty';
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
if (raw.trim().length === 0) return 'empty';
|
||||
const tokens = this.normalizeUserListValue(raw);
|
||||
return tokens.length > 0 ? tokens[0] : 'empty';
|
||||
}
|
||||
return 'no-value';
|
||||
}
|
||||
case 'text':
|
||||
default:
|
||||
return raw ? String(raw).trim() || 'empty' : 'no-value';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error extracting user field value for grouping', e);
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get due date group for task (Today, Tomorrow, This Week, etc.)
|
||||
* For recurring tasks, checks if the task is due on the target date
|
||||
|
|
@ -1157,59 +1321,68 @@ export class FilterService extends EventEmitter {
|
|||
|
||||
let sortedKeys: string[];
|
||||
|
||||
switch (groupKey) {
|
||||
case 'priority':
|
||||
// Sort by priority weight (high to low)
|
||||
sortedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||
const weightA = this.priorityManager.getPriorityWeight(a);
|
||||
const weightB = this.priorityManager.getPriorityWeight(b);
|
||||
return weightB - weightA;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
// Sort by status order
|
||||
sortedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||
const orderA = this.statusManager.getStatusOrder(a);
|
||||
const orderB = this.statusManager.getStatusOrder(b);
|
||||
return orderA - orderB;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'due': {
|
||||
// Sort by logical due date order
|
||||
const dueDateOrder = ['Overdue', 'Today', 'Tomorrow', 'This week', 'Later', 'No due date'];
|
||||
sortedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||
const indexA = dueDateOrder.indexOf(a);
|
||||
const indexB = dueDateOrder.indexOf(b);
|
||||
return indexA - indexB;
|
||||
});
|
||||
break;
|
||||
// Handle dynamic user field sorting
|
||||
if (typeof groupKey === 'string' && groupKey.startsWith('user:')) {
|
||||
sortedKeys = this.sortUserFieldGroups(Array.from(groups.keys()), groupKey);
|
||||
// If the sort key matches the group key, apply sort direction for group headers
|
||||
if (this.currentSortKey === groupKey && this.currentSortDirection === 'desc') {
|
||||
sortedKeys.reverse();
|
||||
}
|
||||
} else {
|
||||
switch (groupKey) {
|
||||
case 'priority':
|
||||
// Sort by priority weight (high to low)
|
||||
sortedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||
const weightA = this.priorityManager.getPriorityWeight(a);
|
||||
const weightB = this.priorityManager.getPriorityWeight(b);
|
||||
return weightB - weightA;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'scheduled': {
|
||||
// Sort by logical scheduled date order
|
||||
const scheduledDateOrder = ['Past scheduled', 'Today', 'Tomorrow', 'This week', 'Later', 'No scheduled date'];
|
||||
sortedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||
const indexA = scheduledDateOrder.indexOf(a);
|
||||
const indexB = scheduledDateOrder.indexOf(b);
|
||||
return indexA - indexB;
|
||||
});
|
||||
break;
|
||||
case 'status':
|
||||
// Sort by status order
|
||||
sortedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||
const orderA = this.statusManager.getStatusOrder(a);
|
||||
const orderB = this.statusManager.getStatusOrder(b);
|
||||
return orderA - orderB;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'due': {
|
||||
// Sort by logical due date order
|
||||
const dueDateOrder = ['Overdue', 'Today', 'Tomorrow', 'This week', 'Later', 'No due date'];
|
||||
sortedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||
const indexA = dueDateOrder.indexOf(a);
|
||||
const indexB = dueDateOrder.indexOf(b);
|
||||
return indexA - indexB;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'scheduled': {
|
||||
// Sort by logical scheduled date order
|
||||
const scheduledDateOrder = ['Past scheduled', 'Today', 'Tomorrow', 'This week', 'Later', 'No scheduled date'];
|
||||
sortedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||
const indexA = scheduledDateOrder.indexOf(a);
|
||||
const indexB = scheduledDateOrder.indexOf(b);
|
||||
return indexA - indexB;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'project':
|
||||
// Sort projects alphabetically with "No Project" at the end
|
||||
sortedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||
if (a === 'No Project') return 1;
|
||||
if (b === 'No Project') return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
// Alphabetical sort for contexts and others
|
||||
sortedKeys = Array.from(groups.keys()).sort();
|
||||
}
|
||||
|
||||
case 'project':
|
||||
// Sort projects alphabetically with "No Project" at the end
|
||||
sortedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||
if (a === 'No Project') return 1;
|
||||
if (b === 'No Project') return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
// Alphabetical sort for contexts and others
|
||||
sortedKeys = Array.from(groups.keys()).sort();
|
||||
}
|
||||
|
||||
// Rebuild map in sorted order
|
||||
|
|
@ -1220,6 +1393,52 @@ export class FilterService extends EventEmitter {
|
|||
return sortedGroups;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sort user-field groups based on field type
|
||||
*/
|
||||
private sortUserFieldGroups(groupKeys: string[], groupKey: string): string[] {
|
||||
const fieldId = groupKey.slice(5);
|
||||
const userFields = this.plugin?.settings?.userFields || [];
|
||||
const field = userFields.find((f: any) => (f.id || f.key) === fieldId);
|
||||
if (!field) return groupKeys.sort();
|
||||
|
||||
switch (field.type) {
|
||||
case 'number':
|
||||
return groupKeys.sort((a, b) => {
|
||||
const numA = parseFloat(a);
|
||||
const numB = parseFloat(b);
|
||||
const isNumA = !isNaN(numA);
|
||||
const isNumB = !isNaN(numB);
|
||||
if (isNumA && isNumB) return numB - numA; // desc
|
||||
if (isNumA && !isNumB) return -1;
|
||||
if (!isNumA && isNumB) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
case 'boolean':
|
||||
return groupKeys.sort((a, b) => {
|
||||
if (a === 'true' && b === 'false') return -1;
|
||||
if (a === 'false' && b === 'true') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
case 'date':
|
||||
return groupKeys.sort((a, b) => {
|
||||
const tA = Date.parse(a);
|
||||
const tB = Date.parse(b);
|
||||
const isValidA = !isNaN(tA);
|
||||
const isValidB = !isNaN(tB);
|
||||
if (isValidA && isValidB) return tA - tB; // asc
|
||||
if (isValidA && !isValidB) return -1;
|
||||
if (!isValidA && isValidB) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
case 'text':
|
||||
case 'list':
|
||||
default:
|
||||
return groupKeys.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available filter options for building FilterBar UI
|
||||
* Uses event-driven caching - cache is invalidated only when new options are detected
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ export type ColorizeMode = 'tasks' | 'notes' | 'daily';
|
|||
export type CalendarDisplayMode = 'month' | 'agenda';
|
||||
|
||||
// Task sorting and grouping types
|
||||
export type TaskSortKey = 'due' | 'scheduled' | 'priority' | 'title' | 'dateCreated';
|
||||
export type TaskGroupKey = 'none' | 'priority' | 'context' | 'project' | 'due' | 'scheduled' | 'status';
|
||||
export type TaskSortKey = 'due' | 'scheduled' | 'priority' | 'title' | 'dateCreated' | `user:${string}`;
|
||||
export type TaskGroupKey = 'none' | 'priority' | 'context' | 'project' | 'due' | 'scheduled' | 'status' | `user:${string}`;
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1255,21 +1255,32 @@ export class FilterBar extends EventEmitter {
|
|||
.onClick(() => {
|
||||
this.currentQuery.sortDirection = this.currentQuery.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
this.updateSortDirectionButton();
|
||||
this.emitQueryChange();
|
||||
this.emitImmediateQueryChange();
|
||||
});
|
||||
|
||||
// Build sort dropdown options, including dynamic user fields
|
||||
const builtInSortOptions: Record<string, string> = {
|
||||
'due': 'Due Date',
|
||||
'scheduled': 'Scheduled Date',
|
||||
'priority': 'Priority',
|
||||
'title': 'Title',
|
||||
'dateCreated': 'Created Date'
|
||||
};
|
||||
const sortOptions: Record<string, string> = { ...builtInSortOptions };
|
||||
const sortUserProps = this.filterOptions.userProperties || [];
|
||||
for (const p of sortUserProps) {
|
||||
if (!p?.id || !p?.label) continue;
|
||||
if (typeof p.id === 'string' && p.id.startsWith('user:')) {
|
||||
sortOptions[p.id] = `${p.label}`;
|
||||
}
|
||||
}
|
||||
|
||||
const sortDropdown = new DropdownComponent(sortContainer)
|
||||
.addOptions({
|
||||
'due': 'Due Date',
|
||||
'scheduled': 'Scheduled Date',
|
||||
'priority': 'Priority',
|
||||
'title': 'Title',
|
||||
'dateCreated': 'Created Date'
|
||||
})
|
||||
.addOptions(sortOptions)
|
||||
.setValue(this.currentQuery.sortKey || 'due')
|
||||
.onChange((value: TaskSortKey) => {
|
||||
this.currentQuery.sortKey = value;
|
||||
this.emitQueryChange();
|
||||
this.emitImmediateQueryChange();
|
||||
});
|
||||
setTooltip(sortDropdown.selectEl, 'Choose how to sort tasks', { placement: 'top' });
|
||||
|
||||
|
|
@ -1277,16 +1288,29 @@ export class FilterBar extends EventEmitter {
|
|||
const groupContainer = controls.createDiv('filter-bar__group-container');
|
||||
groupContainer.createSpan({ text: 'Group by:', cls: 'filter-bar__label' });
|
||||
|
||||
// Build group dropdown options, including dynamic user fields
|
||||
const builtInGroupOptions: Record<string, string> = {
|
||||
'none': 'None',
|
||||
'status': 'Status',
|
||||
'priority': 'Priority',
|
||||
'context': 'Context',
|
||||
'project': 'Project',
|
||||
'due': 'Due Date',
|
||||
'scheduled': 'Scheduled Date'
|
||||
};
|
||||
|
||||
const options: Record<string, string> = { ...builtInGroupOptions };
|
||||
const userProps = this.filterOptions.userProperties || [];
|
||||
for (const p of userProps) {
|
||||
if (!p?.id || !p?.label) continue;
|
||||
// Only add if it is a user property id pattern
|
||||
if (typeof p.id === 'string' && p.id.startsWith('user:')) {
|
||||
options[p.id] = p.label;
|
||||
}
|
||||
}
|
||||
|
||||
const groupDropdown = new DropdownComponent(groupContainer)
|
||||
.addOptions({
|
||||
'none': 'None',
|
||||
'status': 'Status',
|
||||
'priority': 'Priority',
|
||||
'context': 'Context',
|
||||
'project': 'Project',
|
||||
'due': 'Due Date',
|
||||
'scheduled': 'Scheduled Date'
|
||||
})
|
||||
.addOptions(options)
|
||||
.setValue(this.currentQuery.groupKey || 'none')
|
||||
.onChange((value: TaskGroupKey) => {
|
||||
this.currentQuery.groupKey = value;
|
||||
|
|
@ -1813,6 +1837,18 @@ export class FilterBar extends EventEmitter {
|
|||
this.emitQueryChangeIfComplete();
|
||||
}
|
||||
|
||||
/** Emit immediately regardless of filter completeness (for sort/group changes) */
|
||||
private emitImmediateQueryChange(): void {
|
||||
// Clear active saved view when user manually modifies filters (except during saved view loading)
|
||||
if (!this.isLoadingSavedView && this.activeSavedView) {
|
||||
this.clearActiveSavedView();
|
||||
this.updateFilterBuilder();
|
||||
}
|
||||
this.updateFilterToggleBadge();
|
||||
this.emit('queryChange', FilterUtils.deepCloneFilterQuery(this.currentQuery));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the current query is complete and meaningful, then emit if so
|
||||
*/
|
||||
|
|
|
|||
128
tests/unit/services/filterService.groupByUserField.more.test.ts
Normal file
128
tests/unit/services/filterService.groupByUserField.more.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { FilterService } from '../../../src/services/FilterService';
|
||||
import { MinimalNativeCache } from '../../../src/utils/MinimalNativeCache';
|
||||
import { StatusManager } from '../../../src/services/StatusManager';
|
||||
import { PriorityManager } from '../../../src/services/PriorityManager';
|
||||
import { MockObsidian, App } from '../../__mocks__/obsidian';
|
||||
import { DEFAULT_SETTINGS, DEFAULT_FIELD_MAPPING } from '../../../src/settings/defaults';
|
||||
import { FieldMapper } from '../../../src/services/FieldMapper';
|
||||
|
||||
function makeApp(): App { return MockObsidian.createMockApp(); }
|
||||
function makeCache(app: App, settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
const mapper = new FieldMapper(DEFAULT_FIELD_MAPPING);
|
||||
const settings = { ...DEFAULT_SETTINGS, ...settingsOverride } as any;
|
||||
const cache = new MinimalNativeCache(app as any, settings, mapper);
|
||||
cache.initialize();
|
||||
return cache;
|
||||
}
|
||||
function makeFilterService(cache: MinimalNativeCache, plugin: any) {
|
||||
const status = new StatusManager([]);
|
||||
const priority = new PriorityManager([]);
|
||||
return new FilterService(cache, status, priority, plugin);
|
||||
}
|
||||
function createTaskFile(app: App, path: string, frontmatter: Record<string, any>) {
|
||||
const yamlLines = Object.entries(frontmatter)
|
||||
.map(([k, v]) => `${k}: ${Array.isArray(v) ? `[${v.map((x) => typeof x === 'string' ? `'${x.replace(/'/g, "''")}'` : x).join(', ')}]` : v}`);
|
||||
const content = `---\n${yamlLines.join('\n')}\n---\n`;
|
||||
return (app.vault as any).create(path, content);
|
||||
}
|
||||
|
||||
describe('FilterService - group by custom user fields (more cases)', () => {
|
||||
beforeEach(() => { MockObsidian.reset(); });
|
||||
|
||||
test('boolean user field: true/false buckets, true before false', async () => {
|
||||
const app = makeApp();
|
||||
const cache = makeCache(app, { taskIdentificationMethod: 'property', taskPropertyName: 'isTask', taskPropertyValue: 'true' } as any);
|
||||
const plugin = { settings: { ...DEFAULT_SETTINGS, userFields: [
|
||||
{ id: 'flag', key: 'flag', displayName: 'Flag', type: 'boolean' as const }
|
||||
] } };
|
||||
const fs = makeFilterService(cache, plugin);
|
||||
|
||||
await createTaskFile(app, 'Tasks/t1.md', { isTask: true, title: 'T1', flag: true });
|
||||
await createTaskFile(app, 'Tasks/t2.md', { isTask: true, title: 'T2', flag: false });
|
||||
await createTaskFile(app, 'Tasks/t3.md', { isTask: true, title: 'T3' });
|
||||
app.metadataCache.setCache('Tasks/t1.md', { frontmatter: { isTask: true, title: 'T1', flag: true } });
|
||||
app.metadataCache.setCache('Tasks/t2.md', { frontmatter: { isTask: true, title: 'T2', flag: false } });
|
||||
app.metadataCache.setCache('Tasks/t3.md', { frontmatter: { isTask: true, title: 'T3' } });
|
||||
|
||||
const query = fs.createDefaultQuery();
|
||||
(query as any).groupKey = 'user:flag';
|
||||
const grouped = await fs.getGroupedTasks(query);
|
||||
const keys = Array.from(grouped.keys());
|
||||
expect(keys.indexOf('true')).toBeLessThan(keys.indexOf('false'));
|
||||
expect(grouped.get('true')!.length).toBe(1);
|
||||
expect(grouped.get('false')!.length).toBe(1);
|
||||
expect(grouped.get('no-value')!.length).toBe(1);
|
||||
});
|
||||
|
||||
test('list user field: array and string forms; first token; empty vs no-value', async () => {
|
||||
const app = makeApp();
|
||||
const cache = makeCache(app, { taskIdentificationMethod: 'property', taskPropertyName: 'isTask', taskPropertyValue: 'true' } as any);
|
||||
const plugin = { settings: { ...DEFAULT_SETTINGS, userFields: [
|
||||
{ id: 'labels', key: 'labels', displayName: 'Labels', type: 'list' as const }
|
||||
] } };
|
||||
const fs = makeFilterService(cache, plugin);
|
||||
|
||||
await createTaskFile(app, 'Tasks/a.md', { isTask: true, title: 'A', labels: ['[[People/Chuck Norris]]', 'second'] });
|
||||
await createTaskFile(app, 'Tasks/b.md', { isTask: true, title: 'B', labels: 'one, two' });
|
||||
await createTaskFile(app, 'Tasks/c.md', { isTask: true, title: 'C', labels: '' });
|
||||
await createTaskFile(app, 'Tasks/d.md', { isTask: true, title: 'D' });
|
||||
app.metadataCache.setCache('Tasks/a.md', { frontmatter: { isTask: true, title: 'A', labels: ['[[People/Chuck Norris]]', 'second'] } });
|
||||
app.metadataCache.setCache('Tasks/b.md', { frontmatter: { isTask: true, title: 'B', labels: 'one, two' } });
|
||||
app.metadataCache.setCache('Tasks/c.md', { frontmatter: { isTask: true, title: 'C', labels: '' } });
|
||||
app.metadataCache.setCache('Tasks/d.md', { frontmatter: { isTask: true, title: 'D' } });
|
||||
|
||||
const query = fs.createDefaultQuery();
|
||||
(query as any).groupKey = 'user:labels';
|
||||
const grouped = await fs.getGroupedTasks(query);
|
||||
|
||||
// 'a' picks first token normalized from [[People/Chuck Norris]] -> 'Chuck Norris'
|
||||
expect(grouped.get('Chuck Norris')!.map(t => t.path)).toEqual(expect.arrayContaining(['Tasks/a.md']));
|
||||
// 'b' picks first token 'one'
|
||||
expect(grouped.get('one')!.map(t => t.path)).toEqual(expect.arrayContaining(['Tasks/b.md']));
|
||||
// 'c' empty present -> 'empty'
|
||||
expect(grouped.get('empty')!.map(t => t.path)).toEqual(expect.arrayContaining(['Tasks/c.md']));
|
||||
// 'd' missing -> 'no-value'
|
||||
expect(grouped.get('no-value')!.map(t => t.path)).toEqual(expect.arrayContaining(['Tasks/d.md']));
|
||||
});
|
||||
|
||||
test('date user field: chronological sort, no-date fallback', async () => {
|
||||
const app = makeApp();
|
||||
const cache = makeCache(app, { taskIdentificationMethod: 'property', taskPropertyName: 'isTask', taskPropertyValue: 'true' } as any);
|
||||
const plugin = { settings: { ...DEFAULT_SETTINGS, userFields: [
|
||||
{ id: 'review', key: 'review', displayName: 'Review', type: 'date' as const }
|
||||
] } };
|
||||
const fs = makeFilterService(cache, plugin);
|
||||
|
||||
await createTaskFile(app, 'Tasks/x.md', { isTask: true, title: 'X', review: '2025-01-02' });
|
||||
await createTaskFile(app, 'Tasks/y.md', { isTask: true, title: 'Y', review: '2025-01-01' });
|
||||
await createTaskFile(app, 'Tasks/z.md', { isTask: true, title: 'Z' });
|
||||
app.metadataCache.setCache('Tasks/x.md', { frontmatter: { isTask: true, title: 'X', review: '2025-01-02' } });
|
||||
app.metadataCache.setCache('Tasks/y.md', { frontmatter: { isTask: true, title: 'Y', review: '2025-01-01' } });
|
||||
app.metadataCache.setCache('Tasks/z.md', { frontmatter: { isTask: true, title: 'Z' } });
|
||||
|
||||
const query = fs.createDefaultQuery();
|
||||
(query as any).groupKey = 'user:review';
|
||||
const grouped = await fs.getGroupedTasks(query);
|
||||
const keys = Array.from(grouped.keys());
|
||||
expect(keys.indexOf('2025-01-01')).toBeLessThan(keys.indexOf('2025-01-02'));
|
||||
expect(keys).toContain('no-date');
|
||||
});
|
||||
|
||||
test('unknown field id yields unknown-field bucket', async () => {
|
||||
const app = makeApp();
|
||||
const cache = makeCache(app, { taskIdentificationMethod: 'property', taskPropertyName: 'isTask', taskPropertyValue: 'true' } as any);
|
||||
const plugin = { settings: { ...DEFAULT_SETTINGS, userFields: [
|
||||
{ id: 'known', key: 'known', displayName: 'Known', type: 'text' as const }
|
||||
] } };
|
||||
const fs = makeFilterService(cache, plugin);
|
||||
|
||||
await createTaskFile(app, 'Tasks/u.md', { isTask: true, title: 'U', known: 'exists' });
|
||||
app.metadataCache.setCache('Tasks/u.md', { frontmatter: { isTask: true, title: 'U', known: 'exists' } });
|
||||
|
||||
const query = fs.createDefaultQuery();
|
||||
(query as any).groupKey = 'user:unknown';
|
||||
const grouped = await fs.getGroupedTasks(query);
|
||||
expect(grouped.has('unknown-field')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
120
tests/unit/services/filterService.groupByUserField.test.ts
Normal file
120
tests/unit/services/filterService.groupByUserField.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { FilterService } from '../../../src/services/FilterService';
|
||||
import { MinimalNativeCache } from '../../../src/utils/MinimalNativeCache';
|
||||
import { StatusManager } from '../../../src/services/StatusManager';
|
||||
import { PriorityManager } from '../../../src/services/PriorityManager';
|
||||
import { MockObsidian, App } from '../../__mocks__/obsidian';
|
||||
import { DEFAULT_SETTINGS, DEFAULT_FIELD_MAPPING } from '../../../src/settings/defaults';
|
||||
import { FieldMapper } from '../../../src/services/FieldMapper';
|
||||
|
||||
// Helpers
|
||||
function makeApp(): App {
|
||||
return MockObsidian.createMockApp();
|
||||
}
|
||||
|
||||
function makeCache(app: App, settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
const mapper = new FieldMapper(DEFAULT_FIELD_MAPPING);
|
||||
const settings = { ...DEFAULT_SETTINGS, ...settingsOverride } as any;
|
||||
const cache = new MinimalNativeCache(app as any, settings, mapper);
|
||||
cache.initialize();
|
||||
return cache;
|
||||
}
|
||||
|
||||
function makeFilterService(cache: MinimalNativeCache, plugin: any) {
|
||||
const status = new StatusManager([]);
|
||||
const priority = new PriorityManager([]);
|
||||
return new FilterService(cache, status, priority, plugin);
|
||||
}
|
||||
|
||||
function createTaskFile(app: App, path: string, frontmatter: Record<string, any>) {
|
||||
const yamlLines = Object.entries(frontmatter)
|
||||
.map(([k, v]) => `${k}: ${Array.isArray(v) ? `[${v.map((x) => typeof x === 'string' ? `'${x.replace(/'/g, "''")}'` : x).join(', ')}]` : v}`);
|
||||
const content = `---\n${yamlLines.join('\n')}\n---\n`;
|
||||
return (app.vault as any).create(path, content);
|
||||
}
|
||||
|
||||
describe('FilterService - group by custom user fields', () => {
|
||||
beforeEach(() => {
|
||||
MockObsidian.reset();
|
||||
});
|
||||
|
||||
test('groups by text user field with fallback bucket for missing values', async () => {
|
||||
const app = makeApp();
|
||||
const cache = makeCache(app, { taskIdentificationMethod: 'property', taskPropertyName: 'isTask', taskPropertyValue: 'true' } as any);
|
||||
const plugin = { settings: { ...DEFAULT_SETTINGS, userFields: [
|
||||
{ id: 'assignee', key: 'assignee', displayName: 'Assignee', type: 'text' as const }
|
||||
] } };
|
||||
const fs = makeFilterService(cache, plugin);
|
||||
|
||||
// Create task files
|
||||
await createTaskFile(app, 'Tasks/t1.md', { isTask: true, title: 'T1', status: 'open', assignee: 'Alice' });
|
||||
await createTaskFile(app, 'Tasks/t2.md', { isTask: true, title: 'T2', status: 'open', assignee: 'Bob' });
|
||||
await createTaskFile(app, 'Tasks/t3.md', { isTask: true, title: 'T3', status: 'open' }); // no assignee
|
||||
|
||||
// Ensure metadata cache is populated (explicitly, to avoid timing issues)
|
||||
app.metadataCache.setCache('Tasks/t1.md', { frontmatter: { isTask: true, title: 'T1', status: 'open', assignee: 'Alice' } });
|
||||
app.metadataCache.setCache('Tasks/t2.md', { frontmatter: { isTask: true, title: 'T2', status: 'open', assignee: 'Bob' } });
|
||||
app.metadataCache.setCache('Tasks/t3.md', { frontmatter: { isTask: true, title: 'T3', status: 'open' } });
|
||||
|
||||
// Sanity: ensure cache detects tasks
|
||||
const paths = cache.getAllTaskPaths();
|
||||
expect(paths.has('Tasks/t1.md')).toBe(true);
|
||||
expect(paths.has('Tasks/t2.md')).toBe(true);
|
||||
expect(paths.has('Tasks/t3.md')).toBe(true);
|
||||
|
||||
const query = fs.createDefaultQuery();
|
||||
(query as any).groupKey = 'user:assignee';
|
||||
|
||||
const grouped = await fs.getGroupedTasks(query);
|
||||
const keys = Array.from(grouped.keys());
|
||||
|
||||
expect(keys).toContain('Alice');
|
||||
expect(keys).toContain('Bob');
|
||||
expect(keys).toContain('no-value');
|
||||
|
||||
expect(grouped.get('Alice')!.map(t => t.path)).toEqual(expect.arrayContaining(['Tasks/t1.md']));
|
||||
expect(grouped.get('Bob')!.map(t => t.path)).toEqual(expect.arrayContaining(['Tasks/t2.md']));
|
||||
expect(grouped.get('no-value')!.map(t => t.path)).toEqual(expect.arrayContaining(['Tasks/t3.md']));
|
||||
});
|
||||
|
||||
test('groups by number user field and sorts numeric groups descending', async () => {
|
||||
const app = makeApp();
|
||||
const cache = makeCache(app, { taskIdentificationMethod: 'property', taskPropertyName: 'isTask', taskPropertyValue: 'true' } as any);
|
||||
const plugin = { settings: { ...DEFAULT_SETTINGS, userFields: [
|
||||
{ id: 'effort', key: 'effort', displayName: 'Effort', type: 'number' as const }
|
||||
] } };
|
||||
const fs = makeFilterService(cache, plugin);
|
||||
|
||||
await createTaskFile(app, 'Tasks/a.md', { isTask: true, title: 'A', status: 'open', effort: 2 });
|
||||
await createTaskFile(app, 'Tasks/b.md', { isTask: true, title: 'B', status: 'open', effort: '10-High' });
|
||||
await createTaskFile(app, 'Tasks/c.md', { isTask: true, title: 'C', status: 'open' });
|
||||
|
||||
// Ensure metadata cache is populated explicitly
|
||||
app.metadataCache.setCache('Tasks/a.md', { frontmatter: { isTask: true, title: 'A', status: 'open', effort: 2 } });
|
||||
app.metadataCache.setCache('Tasks/b.md', { frontmatter: { isTask: true, title: 'B', status: 'open', effort: '10-High' } });
|
||||
app.metadataCache.setCache('Tasks/c.md', { frontmatter: { isTask: true, title: 'C', status: 'open' } });
|
||||
|
||||
// Sanity: ensure cache detects tasks
|
||||
const paths = cache.getAllTaskPaths();
|
||||
expect(paths.has('Tasks/a.md')).toBe(true);
|
||||
expect(paths.has('Tasks/b.md')).toBe(true);
|
||||
expect(paths.has('Tasks/c.md')).toBe(true);
|
||||
|
||||
const query = fs.createDefaultQuery();
|
||||
(query as any).groupKey = 'user:effort';
|
||||
|
||||
const grouped = await fs.getGroupedTasks(query);
|
||||
const keys = Array.from(grouped.keys());
|
||||
|
||||
// Expect numeric descending, then buckets
|
||||
const idx10 = keys.indexOf('10');
|
||||
const idx2 = keys.indexOf('2');
|
||||
expect(idx10).toBeGreaterThanOrEqual(0);
|
||||
expect(idx2).toBeGreaterThanOrEqual(0);
|
||||
expect(idx10).toBeLessThan(idx2);
|
||||
|
||||
expect(grouped.get('10')!.map(t => t.path)).toEqual(expect.arrayContaining(['Tasks/b.md']));
|
||||
expect(grouped.get('2')!.map(t => t.path)).toEqual(expect.arrayContaining(['Tasks/a.md']));
|
||||
expect(grouped.get('no-value')!.map(t => t.path)).toEqual(expect.arrayContaining(['Tasks/c.md']));
|
||||
});
|
||||
});
|
||||
|
||||
122
tests/unit/services/filterService.sortByUserField.test.ts
Normal file
122
tests/unit/services/filterService.sortByUserField.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { FilterService } from '../../../src/services/FilterService';
|
||||
import { MinimalNativeCache } from '../../../src/utils/MinimalNativeCache';
|
||||
import { StatusManager } from '../../../src/services/StatusManager';
|
||||
import { PriorityManager } from '../../../src/services/PriorityManager';
|
||||
import { MockObsidian, App } from '../../__mocks__/obsidian';
|
||||
import { DEFAULT_SETTINGS, DEFAULT_FIELD_MAPPING } from '../../../src/settings/defaults';
|
||||
import { FieldMapper } from '../../../src/services/FieldMapper';
|
||||
|
||||
function makeApp(): App { return MockObsidian.createMockApp(); }
|
||||
function makeCache(app: App) {
|
||||
const mapper = new FieldMapper(DEFAULT_FIELD_MAPPING);
|
||||
const settings = { ...DEFAULT_SETTINGS, taskIdentificationMethod: 'property', taskPropertyName: 'isTask', taskPropertyValue: 'true' } as any;
|
||||
const cache = new MinimalNativeCache(app as any, settings, mapper);
|
||||
cache.initialize();
|
||||
return cache;
|
||||
}
|
||||
function makeFilterService(cache: MinimalNativeCache, plugin: any) {
|
||||
const status = new StatusManager([]);
|
||||
const priority = new PriorityManager([]);
|
||||
return new FilterService(cache, status, priority, plugin);
|
||||
}
|
||||
function createTaskFile(app: App, path: string, frontmatter: Record<string, any>) {
|
||||
const yamlLines = Object.entries(frontmatter)
|
||||
.map(([k, v]) => `${k}: ${Array.isArray(v) ? `[${v.map((x) => typeof x === 'string' ? `'${x.replace(/'/g, "''")}'` : x).join(', ')}]` : v}`);
|
||||
const content = `---\n${yamlLines.join('\n')}\n---\n`;
|
||||
return (app.vault as any).create(path, content);
|
||||
}
|
||||
|
||||
describe('FilterService - sort by custom user fields', () => {
|
||||
beforeEach(() => { MockObsidian.reset(); });
|
||||
|
||||
test('number: ascending within asc direction; non-numeric last', async () => {
|
||||
const app = makeApp();
|
||||
const cache = makeCache(app);
|
||||
const plugin = { settings: { ...DEFAULT_SETTINGS, userFields: [ { id: 'effort', key: 'effort', displayName: 'Effort', type: 'number' as const } ] } };
|
||||
const fs = makeFilterService(cache, plugin);
|
||||
|
||||
await createTaskFile(app, 'Tasks/a.md', { isTask: true, title: 'A', effort: '10-High' });
|
||||
await createTaskFile(app, 'Tasks/b.md', { isTask: true, title: 'B', effort: 5 });
|
||||
await createTaskFile(app, 'Tasks/c.md', { isTask: true, title: 'C', effort: 'x' });
|
||||
app.metadataCache.setCache('Tasks/a.md', { frontmatter: { isTask: true, title: 'A', effort: '10-High' } });
|
||||
app.metadataCache.setCache('Tasks/b.md', { frontmatter: { isTask: true, title: 'B', effort: 5 } });
|
||||
app.metadataCache.setCache('Tasks/c.md', { frontmatter: { isTask: true, title: 'C', effort: 'x' } });
|
||||
|
||||
const query = fs.createDefaultQuery();
|
||||
(query as any).groupKey = 'none';
|
||||
(query as any).sortKey = 'user:effort';
|
||||
(query as any).sortDirection = 'asc';
|
||||
const groups = await fs.getGroupedTasks(query);
|
||||
const all = groups.get('all')!;
|
||||
expect(all.map(t=>t.path)).toEqual(['Tasks/b.md','Tasks/a.md','Tasks/c.md']);
|
||||
});
|
||||
|
||||
test('boolean: true before false in asc direction', async () => {
|
||||
const app = makeApp();
|
||||
const cache = makeCache(app);
|
||||
const plugin = { settings: { ...DEFAULT_SETTINGS, userFields: [ { id: 'flag', key: 'flag', displayName: 'Flag', type: 'boolean' as const } ] } };
|
||||
const fs = makeFilterService(cache, plugin);
|
||||
|
||||
await createTaskFile(app, 'Tasks/t1.md', { isTask: true, title: 'T1', flag: true });
|
||||
await createTaskFile(app, 'Tasks/t2.md', { isTask: true, title: 'T2', flag: false });
|
||||
await createTaskFile(app, 'Tasks/t3.md', { isTask: true, title: 'T3' });
|
||||
app.metadataCache.setCache('Tasks/t1.md', { frontmatter: { isTask: true, title: 'T1', flag: true } });
|
||||
app.metadataCache.setCache('Tasks/t2.md', { frontmatter: { isTask: true, title: 'T2', flag: false } });
|
||||
app.metadataCache.setCache('Tasks/t3.md', { frontmatter: { isTask: true, title: 'T3' } });
|
||||
|
||||
const query = fs.createDefaultQuery();
|
||||
(query as any).groupKey = 'none';
|
||||
(query as any).sortKey = 'user:flag';
|
||||
(query as any).sortDirection = 'asc';
|
||||
const groups = await fs.getGroupedTasks(query);
|
||||
const all = groups.get('all')!;
|
||||
expect(all.map(t=>t.path)).toEqual(['Tasks/t1.md','Tasks/t2.md','Tasks/t3.md']);
|
||||
});
|
||||
|
||||
test('date: chronological asc', async () => {
|
||||
const app = makeApp();
|
||||
const cache = makeCache(app);
|
||||
const plugin = { settings: { ...DEFAULT_SETTINGS, userFields: [ { id: 'review', key: 'review', displayName: 'Review', type: 'date' as const } ] } };
|
||||
const fs = makeFilterService(cache, plugin);
|
||||
|
||||
await createTaskFile(app, 'Tasks/x.md', { isTask: true, title: 'X', review: '2025-01-02' });
|
||||
await createTaskFile(app, 'Tasks/y.md', { isTask: true, title: 'Y', review: '2025-01-01' });
|
||||
await createTaskFile(app, 'Tasks/z.md', { isTask: true, title: 'Z' });
|
||||
app.metadataCache.setCache('Tasks/x.md', { frontmatter: { isTask: true, title: 'X', review: '2025-01-02' } });
|
||||
app.metadataCache.setCache('Tasks/y.md', { frontmatter: { isTask: true, title: 'Y', review: '2025-01-01' } });
|
||||
app.metadataCache.setCache('Tasks/z.md', { frontmatter: { isTask: true, title: 'Z' } });
|
||||
|
||||
const query = fs.createDefaultQuery();
|
||||
(query as any).groupKey = 'none';
|
||||
(query as any).sortKey = 'user:review';
|
||||
(query as any).sortDirection = 'asc';
|
||||
const groups = await fs.getGroupedTasks(query);
|
||||
const all = groups.get('all')!;
|
||||
expect(all.map(t=>t.path)).toEqual(['Tasks/y.md','Tasks/x.md','Tasks/z.md']);
|
||||
});
|
||||
|
||||
test('list: alphabetical by first token', async () => {
|
||||
const app = makeApp();
|
||||
const cache = makeCache(app);
|
||||
const plugin = { settings: { ...DEFAULT_SETTINGS, userFields: [ { id: 'labels', key: 'labels', displayName: 'Labels', type: 'list' as const } ] } };
|
||||
const fs = makeFilterService(cache, plugin);
|
||||
|
||||
await createTaskFile(app, 'Tasks/a.md', { isTask: true, title: 'A', labels: ['[[People/Chuck Norris]]', 'second'] });
|
||||
await createTaskFile(app, 'Tasks/b.md', { isTask: true, title: 'B', labels: 'one, two' });
|
||||
await createTaskFile(app, 'Tasks/c.md', { isTask: true, title: 'C', labels: '' });
|
||||
await createTaskFile(app, 'Tasks/d.md', { isTask: true, title: 'D' });
|
||||
app.metadataCache.setCache('Tasks/a.md', { frontmatter: { isTask: true, title: 'A', labels: ['[[People/Chuck Norris]]', 'second'] } });
|
||||
app.metadataCache.setCache('Tasks/b.md', { frontmatter: { isTask: true, title: 'B', labels: 'one, two' } });
|
||||
app.metadataCache.setCache('Tasks/c.md', { frontmatter: { isTask: true, title: 'C', labels: '' } });
|
||||
app.metadataCache.setCache('Tasks/d.md', { frontmatter: { isTask: true, title: 'D' } });
|
||||
|
||||
const query = fs.createDefaultQuery();
|
||||
(query as any).groupKey = 'none';
|
||||
(query as any).sortKey = 'user:labels';
|
||||
(query as any).sortDirection = 'asc';
|
||||
const groups = await fs.getGroupedTasks(query);
|
||||
const all = groups.get('all')!;
|
||||
expect(all.map(t=>t.path)).toEqual(['Tasks/a.md','Tasks/b.md','Tasks/c.md','Tasks/d.md']);
|
||||
});
|
||||
});
|
||||
|
||||
63
tests/unit/ui/FilterBar.group-dropdown.user-fields.test.ts
Normal file
63
tests/unit/ui/FilterBar.group-dropdown.user-fields.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { FilterBar } from '../../../src/ui/FilterBar';
|
||||
import { FilterQuery, FilterOptions } from '../../../src/types';
|
||||
|
||||
/**
|
||||
* FilterBar Group By dropdown - user fields integration
|
||||
*/
|
||||
|
||||
describe('FilterBar Group By dropdown - user fields', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
document.body.innerHTML = '';
|
||||
(HTMLElement.prototype as any).empty = function() { this.innerHTML=''; return this; };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function createQuery(): FilterQuery {
|
||||
return { type: 'group', id: 'root', conjunction: 'and', children: [], sortKey: 'due', sortDirection: 'asc', groupKey: 'none' } as any;
|
||||
}
|
||||
|
||||
function createOptions(): FilterOptions {
|
||||
return {
|
||||
statuses: [], priorities: [], contexts: [], projects: [], tags: [], folders: [],
|
||||
userProperties: [
|
||||
{ id: 'user:assignee', label: 'Assignee' },
|
||||
{ id: 'user:effort', label: 'Effort' }
|
||||
] as any
|
||||
} as any;
|
||||
}
|
||||
|
||||
test('renders user fields in Group By dropdown and preserves selection after re-render', () => {
|
||||
const container: any = document.createElement('div');
|
||||
container.empty = function(){ this.innerHTML=''; return this; };
|
||||
document.body.appendChild(container);
|
||||
|
||||
const fb = new FilterBar(({} as any), container, createQuery(), createOptions());
|
||||
|
||||
// Find the group select
|
||||
const select = container.querySelector('.filter-bar__group-container select') as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
|
||||
// Ensure options include user fields
|
||||
const opts = Array.from(select.options).map(o => ({ value: o.value, text: o.textContent }));
|
||||
const hasAssignee = opts.some(o => o.value === 'user:assignee' && o.text === 'Assignee');
|
||||
const hasEffort = opts.some(o => o.value === 'user:effort' && o.text === 'Effort');
|
||||
expect(hasAssignee).toBe(true);
|
||||
expect(hasEffort).toBe(true);
|
||||
|
||||
// Change selection to a user field
|
||||
select.value = 'user:assignee';
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
jest.advanceTimersByTime(310);
|
||||
|
||||
// Force filter options update (simulates settings change) and verify selection persists
|
||||
(fb as any).updateFilterOptions(createOptions());
|
||||
const select2 = container.querySelector('.filter-bar__group-container select') as HTMLSelectElement;
|
||||
expect(select2.value).toBe('user:assignee');
|
||||
});
|
||||
});
|
||||
|
||||
52
tests/unit/ui/FilterBar.sort-dropdown.user-fields.test.ts
Normal file
52
tests/unit/ui/FilterBar.sort-dropdown.user-fields.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { FilterBar } from '../../../src/ui/FilterBar';
|
||||
import { FilterQuery, FilterOptions } from '../../../src/types';
|
||||
|
||||
describe('FilterBar Sort dropdown - user fields', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
document.body.innerHTML = '';
|
||||
(HTMLElement.prototype as any).empty = function() { this.innerHTML=''; return this; };
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function createQuery(): FilterQuery {
|
||||
return { type: 'group', id: 'root', conjunction: 'and', children: [], sortKey: 'due', sortDirection: 'asc', groupKey: 'none' } as any;
|
||||
}
|
||||
|
||||
function createOptions(): FilterOptions {
|
||||
return {
|
||||
statuses: [], priorities: [], contexts: [], projects: [], tags: [], folders: [],
|
||||
userProperties: [
|
||||
{ id: 'user:assignee', label: 'Assignee' },
|
||||
{ id: 'user:effort', label: 'Effort' }
|
||||
] as any
|
||||
} as any;
|
||||
}
|
||||
|
||||
test('renders user fields in Sort dropdown and preserves selection', () => {
|
||||
const container: any = document.createElement('div');
|
||||
container.empty = function(){ this.innerHTML=''; return this; };
|
||||
document.body.appendChild(container);
|
||||
|
||||
const fb = new FilterBar(({} as any), container, createQuery(), createOptions());
|
||||
|
||||
const select = container.querySelector('.filter-bar__sort-container select') as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
|
||||
const opts = Array.from(select.options).map(o => ({ value: o.value, text: o.textContent }));
|
||||
expect(opts.some(o => o.value === 'user:assignee' && o.text === 'Assignee')).toBe(true);
|
||||
expect(opts.some(o => o.value === 'user:effort' && o.text === 'Effort')).toBe(true);
|
||||
|
||||
select.value = 'user:assignee';
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
jest.advanceTimersByTime(310);
|
||||
|
||||
(fb as any).updateFilterOptions(createOptions());
|
||||
const select2 = container.querySelector('.filter-bar__sort-container select') as HTMLSelectElement;
|
||||
expect(select2.value).toBe('user:assignee');
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in a new issue