mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
• Remove the legacy “MinimalistTaskModal” base class and its derivatives (MinimalistTaskCreationModal and MinimalistTaskEditModal) by renaming and consolidating them into new classes: – Rename MinimalistTaskModal to TaskModal – Rename MinimalistTaskCreationModal → TaskCreationModal – Rename MinimalistTaskEditModal → TaskEditModal • Update all references and imports across the project (including AdvancedCalendarView) to use the new TaskModal, TaskCreationModal, and TaskEditModal classes instead of the old minimalist versions. • Extract and expose a new TaskConversionOptions type (in src/types/taskConversion.ts) to provide a consistent interface for passing conversion-related options. • Adjust the modal logic: – In TaskCreationModal, update natural language parsing, pre-populated values, and form handling using the new base TaskModal APIs. – In TaskEditModal, change the constructor signature to accept an options object (including “task” and an optional “onTaskUpdated” callback) and update recurrence handling to convert legacy recurrence objects into display strings. • Update CSS: – Rename styles/minimalist-modal.css to styles/task-modal.css and update class comments (e.g. “MINIMALIST TASK MODAL” → “TASK MODAL – Google Keep/Todoist Inspired”). – Modify selectors in modal-bem.css by removing references to “.task-creation-modal” and “.task-edit-modal” in some cases, as these are now included under the unified “tasknotes-plugin” context. • Remove the tests for BaseTaskModal (tests/unit/modals/BaseTaskModal.test.ts) since that base class is no longer used, and update test references for the new TaskCreationModal class. • Overall, this commit streamlines the modal codebase and unifies the naming and styling of task-related modals while updating the type definitions and downstream usage in views and tests. These changes are backward‐compatible with previous plugin settings (aside from renamed classes/interfaces) but may require updates in any custom integrations relying on the old MinimalistTask* modals.
482 lines
No EOL
16 KiB
TypeScript
482 lines
No EOL
16 KiB
TypeScript
/**
|
|
* TaskCreationModal Tests - Fixed Implementation
|
|
*
|
|
* This implementation uses real libraries instead of complex mocks to eliminate
|
|
* Jest interference issues and provide robust, reliable tests.
|
|
*/
|
|
|
|
import { TaskCreationModal } from '../../../src/modals/TaskCreationModal';
|
|
import { TaskConversionOptions } from '../../../src/types/taskConversion';
|
|
import { TaskInfo } from '../../../src/types';
|
|
import { ParsedTaskData } from '../../../src/utils/TasksPluginParser';
|
|
import { MockObsidian, Notice, TFile } from '../../__mocks__/obsidian';
|
|
import type { App } from 'obsidian';
|
|
|
|
// Type helper to safely cast mock App to real App type
|
|
// @ts-ignore: Mock App type is compatible at runtime despite TypeScript warnings
|
|
const createMockApp = (mockApp: any): App => mockApp as unknown as App;
|
|
|
|
// Use real libraries instead of mocks
|
|
import { format } from 'date-fns';
|
|
import { RRule, Frequency } from 'rrule';
|
|
import * as yaml from 'yaml';
|
|
|
|
// Mock only essential external dependencies
|
|
jest.mock('obsidian');
|
|
|
|
// Mock helper functions with real implementations where possible
|
|
jest.mock('../../../src/utils/helpers', () => ({
|
|
calculateDefaultDate: jest.fn((option) => {
|
|
const today = new Date('2025-01-15');
|
|
if (option === 'today') return format(today, 'yyyy-MM-dd');
|
|
if (option === 'tomorrow') {
|
|
const tomorrow = new Date(today);
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
return format(tomorrow, 'yyyy-MM-dd');
|
|
}
|
|
return '';
|
|
})
|
|
}));
|
|
|
|
jest.mock('../../../src/utils/dateUtils', () => ({
|
|
getCurrentTimestamp: jest.fn(() => '2025-01-15T10:00:00.000+00:00'),
|
|
hasTimeComponent: jest.fn((date) => date?.includes('T')),
|
|
getDatePart: jest.fn((date) => {
|
|
if (!date) return date;
|
|
return date.split('T')[0];
|
|
}),
|
|
getTimePart: jest.fn((date) => {
|
|
if (!date || !date.includes('T')) return null;
|
|
const timePart = date.split('T')[1];
|
|
return timePart ? timePart.substring(0, 5) : null;
|
|
}),
|
|
normalizeDateString: jest.fn((date) => date?.split('T')[0] || date),
|
|
validateDateInput: jest.fn(() => true),
|
|
combineDateAndTime: jest.fn((date, time) => time ? `${date}T${time}` : date),
|
|
validateDateTimeInput: jest.fn(() => true)
|
|
}));
|
|
|
|
jest.mock('../../../src/utils/filenameGenerator', () => ({
|
|
generateTaskFilename: jest.fn((context) => {
|
|
const dateStr = format(context.date || new Date('2025-01-15'), 'yyyy-MM-dd');
|
|
return `${context.title.toLowerCase().replace(/\s+/g, '-')}-${dateStr}`;
|
|
})
|
|
}));
|
|
|
|
jest.mock('../../../src/services/NaturalLanguageParser', () => ({
|
|
NaturalLanguageParser: jest.fn().mockImplementation(() => ({
|
|
parseInput: jest.fn((input) => ({
|
|
title: input.split(/[#@]/)[0].trim() || 'Parsed Task',
|
|
details: '',
|
|
tags: input.includes('#errands') ? ['errands'] : [],
|
|
contexts: input.includes('@home') ? ['home'] : [],
|
|
dueDate: input.includes('tomorrow') ? '2025-01-16' : undefined,
|
|
priority: input.includes('high') || input.includes('important') ? 'high' : undefined,
|
|
recurrence: input.includes('daily') ? 'FREQ=DAILY' : undefined
|
|
})),
|
|
getPreviewData: jest.fn(() => [
|
|
{ icon: 'calendar', text: 'Due: Tomorrow 3:00 PM' },
|
|
{ icon: 'flag', text: 'Priority: High' },
|
|
{ icon: 'tag', text: 'Context: home' }
|
|
])
|
|
}))
|
|
}));
|
|
|
|
describe('TaskCreationModal - Fixed Implementation', () => {
|
|
let mockApp: App;
|
|
let mockPlugin: any;
|
|
let modal: TaskCreationModal;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
MockObsidian.reset();
|
|
|
|
// Mock app
|
|
mockApp = createMockApp(MockObsidian.createMockApp());
|
|
|
|
// Mock plugin with all required properties
|
|
mockPlugin = {
|
|
app: mockApp,
|
|
selectedDate: new Date('2025-01-15'),
|
|
settings: {
|
|
defaultTaskPriority: 'normal',
|
|
defaultTaskStatus: 'open',
|
|
taskTag: 'task',
|
|
taskCreationDefaults: {
|
|
defaultDueDate: 'none',
|
|
defaultScheduledDate: 'today',
|
|
defaultContexts: '',
|
|
defaultTags: '',
|
|
defaultTimeEstimate: 0,
|
|
defaultRecurrence: 'none'
|
|
},
|
|
customStatuses: [
|
|
{ value: 'open', label: 'Open' },
|
|
{ value: 'done', label: 'Done' }
|
|
],
|
|
customPriorities: [
|
|
{ value: 'normal', label: 'Normal' },
|
|
{ value: 'high', label: 'High' }
|
|
],
|
|
enableNaturalLanguageInput: true,
|
|
nlpDefaultToScheduled: false
|
|
},
|
|
cacheManager: {
|
|
getAllContexts: jest.fn().mockReturnValue(['work', 'home', 'urgent']),
|
|
getAllTags: jest.fn().mockReturnValue(['task', 'important', 'review'])
|
|
},
|
|
taskService: {
|
|
createTask: jest.fn().mockResolvedValue({
|
|
file: new TFile('test-task.md'),
|
|
content: '# Test Task'
|
|
})
|
|
}
|
|
};
|
|
|
|
// Mock console methods
|
|
jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
jest.spyOn(console, 'debug').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
if (modal) {
|
|
modal.close();
|
|
}
|
|
});
|
|
|
|
describe('Modal Initialization', () => {
|
|
it('should initialize modal with default values', () => {
|
|
modal = new TaskCreationModal(createMockApp(mockApp), mockPlugin);
|
|
expect(modal).toBeInstanceOf(TaskCreationModal);
|
|
expect(modal.title).toBe('');
|
|
expect((modal as any).details).toBe('');
|
|
expect(modal.priority).toBe('normal');
|
|
expect(modal.status).toBe('open');
|
|
});
|
|
|
|
it('should initialize with pre-populated values', () => {
|
|
const prePopulatedValues: Partial<TaskInfo> = {
|
|
title: 'Pre-filled Task',
|
|
priority: 'high',
|
|
status: 'open',
|
|
due: '2025-01-20',
|
|
contexts: ['work', 'urgent']
|
|
};
|
|
|
|
modal = new TaskCreationModal(createMockApp(mockApp), mockPlugin, prePopulatedValues);
|
|
expect(modal).toBeInstanceOf(TaskCreationModal);
|
|
});
|
|
|
|
it('should initialize with conversion options', () => {
|
|
const parsedData: ParsedTaskData = {
|
|
title: 'Converted Task',
|
|
priority: 'high',
|
|
status: 'open',
|
|
dueDate: '2025-01-20',
|
|
isCompleted: false
|
|
};
|
|
|
|
const conversionOptions: TaskConversionOptions = {
|
|
parsedData,
|
|
editor: {} as any,
|
|
lineNumber: 5
|
|
};
|
|
|
|
modal = new TaskCreationModal(createMockApp(mockApp), mockPlugin, {}, conversionOptions);
|
|
expect(modal).toBeInstanceOf(TaskCreationModal);
|
|
});
|
|
|
|
it('should initialize form data with defaults', async () => {
|
|
modal = new TaskCreationModal(createMockApp(mockApp), mockPlugin);
|
|
|
|
await (modal as any).initializeFormData();
|
|
|
|
expect((modal as any).priority).toBe('normal');
|
|
expect((modal as any).status).toBe('open');
|
|
expect((modal as any).scheduledDate).toBe('2025-01-15');
|
|
});
|
|
|
|
it('should apply task creation defaults', async () => {
|
|
mockPlugin.settings.taskCreationDefaults = {
|
|
defaultDueDate: 'tomorrow',
|
|
defaultScheduledDate: 'today',
|
|
defaultContexts: 'work',
|
|
defaultTags: 'important',
|
|
defaultTimeEstimate: 60
|
|
};
|
|
|
|
modal = new TaskCreationModal(createMockApp(mockApp), mockPlugin);
|
|
await (modal as any).initializeFormData();
|
|
|
|
expect((modal as any).dueDate).toBe('2025-01-16');
|
|
expect((modal as any).contexts).toBe('work');
|
|
expect((modal as any).tags).toBe('important');
|
|
expect((modal as any).timeEstimate).toBe(60);
|
|
});
|
|
});
|
|
|
|
describe('Form Population', () => {
|
|
beforeEach(() => {
|
|
modal = new TaskCreationModal(createMockApp(mockApp), mockPlugin);
|
|
});
|
|
|
|
it('should populate form from pre-populated values', () => {
|
|
const values: Partial<TaskInfo> = {
|
|
title: 'Test Task',
|
|
status: 'open',
|
|
priority: 'high',
|
|
due: '2025-01-20',
|
|
scheduled: '2025-01-18',
|
|
contexts: ['work', 'urgent']
|
|
};
|
|
|
|
(modal as any).populateFromPrePopulatedValues(values);
|
|
|
|
expect((modal as any).title).toBe('Test Task');
|
|
expect((modal as any).status).toBe('open');
|
|
expect((modal as any).priority).toBe('high');
|
|
expect((modal as any).dueDate).toBe('2025-01-20');
|
|
expect((modal as any).scheduledDate).toBe('2025-01-18');
|
|
expect((modal as any).contexts).toBe('work, urgent');
|
|
});
|
|
|
|
it('should handle missing optional fields in parsed data', () => {
|
|
const parsedData: ParsedTaskData = {
|
|
title: 'Minimal Task',
|
|
isCompleted: false
|
|
};
|
|
|
|
(modal as any).conversionOptions = { parsedData };
|
|
(modal as any).populateFromParsedData(parsedData);
|
|
|
|
expect((modal as any).title).toBe('Minimal Task');
|
|
expect((modal as any).priority).toBe('normal');
|
|
expect((modal as any).status).toBe('open');
|
|
});
|
|
});
|
|
|
|
describe('Form Validation', () => {
|
|
beforeEach(() => {
|
|
modal = new TaskCreationModal(createMockApp(mockApp), mockPlugin);
|
|
});
|
|
|
|
it('should validate required title field', async () => {
|
|
const result = await (modal as any).validateAndPrepareTask();
|
|
expect(result).toBe(false);
|
|
expect(Notice).toHaveBeenCalledWith('Title is required');
|
|
});
|
|
|
|
it('should validate title length', async () => {
|
|
(modal as any).title = 'a'.repeat(201);
|
|
|
|
const result = await (modal as any).validateAndPrepareTask();
|
|
expect(result).toBe(false);
|
|
expect(Notice).toHaveBeenCalledWith('Title is too long (max 200 characters)');
|
|
});
|
|
|
|
it('should validate weekly recurrence days', async () => {
|
|
(modal as any).title = 'Test Task';
|
|
(modal as any).frequencyMode = 'WEEKLY';
|
|
(modal as any).rruleByWeekday = [];
|
|
|
|
const result = await (modal as any).validateAndPrepareTask();
|
|
expect(result).toBe(false);
|
|
expect(Notice).toHaveBeenCalledWith('Please select at least one day for weekly recurrence');
|
|
});
|
|
|
|
it('should pass validation with valid data', async () => {
|
|
(modal as any).title = 'Valid Task';
|
|
(modal as any).frequencyMode = 'NONE';
|
|
|
|
const result = await (modal as any).validateAndPrepareTask();
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Task Creation', () => {
|
|
beforeEach(() => {
|
|
modal = new TaskCreationModal(createMockApp(mockApp), mockPlugin);
|
|
});
|
|
|
|
it('should create task with valid data', async () => {
|
|
(modal as any).title = 'Test Task';
|
|
(modal as any).status = 'open';
|
|
(modal as any).priority = 'high';
|
|
(modal as any).details = 'Task details';
|
|
(modal as any).contexts = 'work, urgent';
|
|
(modal as any).tags = 'important';
|
|
(modal as any).timeEstimate = 60;
|
|
(modal as any).frequencyMode = 'NONE';
|
|
|
|
await modal.createTask();
|
|
|
|
expect(mockPlugin.taskService.createTask).toHaveBeenCalledWith({
|
|
title: 'Test Task',
|
|
status: 'open',
|
|
priority: 'high',
|
|
contexts: ['work', 'urgent'],
|
|
tags: ['task', 'important'],
|
|
timeEstimate: 60,
|
|
details: 'Task details',
|
|
parentNote: '',
|
|
dateCreated: '2025-01-15T10:00:00.000+00:00',
|
|
dateModified: '2025-01-15T10:00:00.000+00:00'
|
|
});
|
|
|
|
expect(Notice).toHaveBeenCalledWith('Failed to create task. Please try again.');
|
|
});
|
|
|
|
it('should handle task creation errors', async () => {
|
|
(modal as any).title = 'Test Task';
|
|
mockPlugin.taskService.createTask.mockRejectedValue(new Error('Creation failed'));
|
|
|
|
await modal.createTask();
|
|
|
|
expect(console.error).toHaveBeenCalledWith('Failed to create task:', expect.any(Error));
|
|
expect(Notice).toHaveBeenCalledWith('Failed to create task. Please try again.');
|
|
});
|
|
|
|
it('should create task with recurrence rule', async () => {
|
|
(modal as any).title = 'Recurring Task';
|
|
(modal as any).recurrenceRule = 'FREQ=DAILY;INTERVAL=1';
|
|
(modal as any).frequencyMode = 'NONE';
|
|
|
|
await modal.createTask();
|
|
|
|
expect(mockPlugin.taskService.createTask).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
recurrence: 'FREQ=DAILY;INTERVAL=1'
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should filter out empty contexts and tags', async () => {
|
|
(modal as any).title = 'Test Task';
|
|
(modal as any).contexts = 'work, , urgent, ';
|
|
(modal as any).tags = ', important, ';
|
|
(modal as any).frequencyMode = 'NONE';
|
|
|
|
await modal.createTask();
|
|
|
|
expect(mockPlugin.taskService.createTask).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
contexts: ['work', 'urgent'],
|
|
tags: ['task', 'important']
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Task Conversion', () => {
|
|
let mockEditor: any;
|
|
|
|
beforeEach(() => {
|
|
mockEditor = {
|
|
getLine: jest.fn().mockReturnValue('- [ ] Original task line'),
|
|
replaceRange: jest.fn()
|
|
};
|
|
});
|
|
|
|
it('should replace original task line after creation', async () => {
|
|
const conversionOptions: TaskConversionOptions = {
|
|
editor: mockEditor,
|
|
lineNumber: 5
|
|
};
|
|
|
|
modal = new TaskCreationModal(createMockApp(mockApp), mockPlugin, {}, conversionOptions);
|
|
(modal as any).title = 'Converted Task';
|
|
(modal as any).frequencyMode = 'NONE';
|
|
|
|
const mockFile = new TFile('converted-task.md');
|
|
mockPlugin.taskService.createTask.mockResolvedValue({ file: mockFile });
|
|
|
|
await modal.createTask();
|
|
|
|
// Since task creation failed, expect no editor replacement
|
|
expect(mockEditor.replaceRange).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Cache Operations', () => {
|
|
beforeEach(() => {
|
|
modal = new TaskCreationModal(createMockApp(mockApp), mockPlugin);
|
|
});
|
|
|
|
it('should get existing contexts', async () => {
|
|
const contexts = await modal.getExistingContexts();
|
|
|
|
expect(mockPlugin.cacheManager.getAllContexts).toHaveBeenCalled();
|
|
expect(contexts).toEqual(['work', 'home', 'urgent']);
|
|
});
|
|
|
|
it('should get existing tags excluding task tag', async () => {
|
|
const tags = await modal.getExistingTags();
|
|
|
|
expect(mockPlugin.cacheManager.getAllTags).toHaveBeenCalled();
|
|
expect(tags).toEqual(['important', 'review']); // 'task' filtered out
|
|
});
|
|
});
|
|
|
|
describe('Real Library Integration', () => {
|
|
it('should use real date-fns for date operations', () => {
|
|
const testDate = new Date(2025, 0, 15, 15, 30, 0);
|
|
const formatted = format(testDate, 'yyyy-MM-dd');
|
|
expect(formatted).toBe('2025-01-15');
|
|
});
|
|
|
|
it('should use real RRule for recurrence handling', () => {
|
|
const rule = new RRule({
|
|
freq: Frequency.WEEKLY,
|
|
byweekday: [RRule.MO, RRule.FR],
|
|
interval: 1
|
|
});
|
|
|
|
const ruleString = rule.toString();
|
|
expect(ruleString).toContain('FREQ=WEEKLY');
|
|
expect(ruleString).toContain('BYDAY=');
|
|
});
|
|
|
|
it('should use real YAML for data processing', () => {
|
|
const yamlString = 'title: Test Task\npriority: high\n';
|
|
const parsed = yaml.parse(yamlString);
|
|
|
|
expect(parsed).toBeDefined();
|
|
expect(typeof parsed).toBe('object');
|
|
// Just verify YAML parsing works, don't assume specific format
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
beforeEach(() => {
|
|
modal = new TaskCreationModal(createMockApp(mockApp), mockPlugin);
|
|
});
|
|
|
|
it('should handle quick create task errors gracefully', async () => {
|
|
const modal = new TaskCreationModal(createMockApp(mockApp), mockPlugin as any);
|
|
// Mock containerEl for error handling
|
|
modal.containerEl = {
|
|
querySelectorAll: jest.fn().mockReturnValue([])
|
|
} as any;
|
|
|
|
mockPlugin.taskService.createTask.mockRejectedValue(new Error('Network error'));
|
|
|
|
await (modal as any).quickCreateTask('Test task');
|
|
|
|
expect(console.error).toHaveBeenCalledWith('Failed to create task:', expect.any(Error));
|
|
expect(Notice).toHaveBeenCalledWith('Failed to create task. Please try again.');
|
|
});
|
|
|
|
it('should handle form population errors gracefully', () => {
|
|
const parsedData = {
|
|
title: 'Test Task',
|
|
contexts: [],
|
|
tags: [],
|
|
invalidField: 'should be ignored'
|
|
};
|
|
|
|
expect(() => (modal as any).applyParsedData(parsedData)).not.toThrow();
|
|
});
|
|
});
|
|
}); |