mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
Refactor and consolidate modal implementation, update naming and imports, and adjust styles and tests
• 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.
This commit is contained in:
parent
169449c8b8
commit
b4fe29068b
16 changed files with 610 additions and 4424 deletions
|
|
@ -234,7 +234,7 @@ Let's say you want to add a `complexity: 'simple' | 'medium' | 'hard'` property
|
|||
4. **Update `TaskService.ts`**:
|
||||
* In `createTask`, handle the new property, possibly applying a default value.
|
||||
* In `updateTask`, ensure the new property can be updated.
|
||||
5. **Update `BaseTaskModal.ts`**: Add a dropdown or input field to `TaskCreationModal` and `TaskEditModal` to allow users to set the complexity.
|
||||
5. **Update `TaskModal.ts`**: Add a dropdown or input field to `TaskCreationModal` and `TaskEditModal` to allow users to set the complexity.
|
||||
6. **Update `TaskCard.ts`**: In `createTaskCard` and `updateTaskCard`, add logic to display the new complexity information (e.g., an icon or text in the metadata line).
|
||||
7. **Update `FilterService.ts` (Optional)**: If you want to filter by complexity frequently:
|
||||
* Consider whether an index is needed (probably not - compute on-demand)
|
||||
|
|
|
|||
218
build-css.mjs
218
build-css.mjs
|
|
@ -13,7 +13,7 @@ const CSS_FILES = [
|
|||
'styles/note-card-bem.css', // NoteCard component with proper BEM scoping
|
||||
'styles/filter-bar-bem.css', // FilterBar component with proper BEM scoping
|
||||
'styles/modal-bem.css', // Modal components with proper BEM scoping
|
||||
'styles/minimalist-modal.css', // Minimalist modal components (Google Keep/Todoist style)
|
||||
'styles/task-modal.css', // Task modal components (Google Keep/Todoist style)
|
||||
'styles/date-picker.css', // Enhanced date/time picker styling
|
||||
'styles/task-selector-modal.css', // TaskSelectorModal component with proper BEM scoping
|
||||
'styles/unscheduled-tasks-selector-modal.css', // UnscheduledTasksSelectorModal component with proper BEM scoping
|
||||
|
|
@ -66,221 +66,7 @@ const MAIN_CSS_TEMPLATE = `/* TaskNotes Plugin Styles */
|
|||
|
||||
`;
|
||||
|
||||
const REMAINING_STYLES = `
|
||||
/* Task Creation Modal */
|
||||
.task-creation-modal .input-with-counter {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.task-creation-modal .character-counter {
|
||||
position: absolute;
|
||||
top: -1.2rem;
|
||||
right: 0;
|
||||
font-size: var(--cs-text-xs);
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-creation-modal .character-counter.warning {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
.task-creation-modal .autocomplete-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.task-creation-modal .autocomplete-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--cs-radius-sm);
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
font-size: var(--cs-text-base);
|
||||
}
|
||||
|
||||
.task-creation-modal .autocomplete-suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 var(--cs-radius-sm) var(--cs-radius-sm);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.task-creation-modal .autocomplete-suggestion {
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--background-modifier-border-hover);
|
||||
font-size: var(--cs-text-sm);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.task-creation-modal .autocomplete-suggestion:hover,
|
||||
.task-creation-modal .autocomplete-suggestion.selected {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.task-creation-modal .autocomplete-suggestion:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.task-creation-modal .form-group {
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.task-creation-modal .form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-normal);
|
||||
font-size: var(--cs-text-sm);
|
||||
}
|
||||
|
||||
.task-creation-modal input,
|
||||
.task-creation-modal select,
|
||||
.task-creation-modal textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--cs-radius-sm);
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
font-size: var(--cs-text-base);
|
||||
transition: border-color var(--cs-transition-fast);
|
||||
}
|
||||
|
||||
.task-creation-modal input:focus,
|
||||
.task-creation-modal select:focus,
|
||||
.task-creation-modal textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--interactive-accent);
|
||||
box-shadow: 0 0 0 2px rgba(var(--interactive-accent-rgb), 0.2);
|
||||
}
|
||||
|
||||
.task-creation-modal .button-container {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.task-creation-modal .create-button {
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--cs-radius-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--cs-transition-fast);
|
||||
}
|
||||
|
||||
.task-creation-modal .create-button:hover {
|
||||
background: var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
.task-creation-modal .recurrence-helper-text {
|
||||
font-size: var(--cs-text-xs);
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.task-creation-modal .days-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.task-creation-modal .day-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: var(--cs-text-sm);
|
||||
}
|
||||
|
||||
.task-creation-modal .day-checkbox {
|
||||
width: auto !important;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-creation-modal .time-estimate-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.task-creation-modal .time-estimate-container input {
|
||||
width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-creation-modal .time-unit-label {
|
||||
font-size: var(--cs-text-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Recurrence options styling */
|
||||
.task-creation-modal .recurrence-pattern-group {
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--cs-radius-sm);
|
||||
padding: var(--cs-spacing-sm);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
/* Days of week selection */
|
||||
.task-creation-modal .days-of-week-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: var(--cs-spacing-xs);
|
||||
margin-top: var(--cs-spacing-sm);
|
||||
}
|
||||
|
||||
.task-creation-modal .day-button {
|
||||
padding: var(--cs-spacing-xs) var(--cs-spacing-sm);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--cs-radius-sm);
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
font-size: var(--cs-text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
transition: all var(--cs-transition-fast);
|
||||
text-align: center;
|
||||
min-height: var(--cs-touch-target);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.task-creation-modal .day-button:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.task-creation-modal .day-button.selected {
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.task-creation-modal .day-button:focus {
|
||||
outline: 2px solid var(--interactive-accent);
|
||||
outline-offset: 2px;
|
||||
}`;
|
||||
const REMAINING_STYLES = ``;
|
||||
|
||||
function buildCSS() {
|
||||
console.log('Building CSS...');
|
||||
|
|
|
|||
16
src/main.ts
16
src/main.ts
|
|
@ -34,9 +34,9 @@ import { AgendaView } from './views/AgendaView';
|
|||
import { PomodoroView } from './views/PomodoroView';
|
||||
import { PomodoroStatsView } from './views/PomodoroStatsView';
|
||||
import { KanbanView } from './views/KanbanView';
|
||||
import { TaskCreationModal, TaskConversionOptions } from './modals/TaskCreationModal';
|
||||
import { MinimalistTaskCreationModal } from './modals/MinimalistTaskCreationModal';
|
||||
import { MinimalistTaskEditModal } from './modals/MinimalistTaskEditModal';
|
||||
import { TaskConversionOptions } from './types/taskConversion';
|
||||
import { TaskCreationModal } from './modals/TaskCreationModal';
|
||||
import { TaskEditModal } from './modals/TaskEditModal';
|
||||
import { PomodoroService } from './services/PomodoroService';
|
||||
import {
|
||||
formatTime,
|
||||
|
|
@ -951,7 +951,7 @@ private injectCustomStyles(): void {
|
|||
}
|
||||
|
||||
openTaskCreationModal(prePopulatedValues?: Partial<TaskInfo>) {
|
||||
new MinimalistTaskCreationModal(this.app, this, { prePopulatedValues }).open();
|
||||
new TaskCreationModal(this.app, this, { prePopulatedValues }).open();
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1024,7 +1024,7 @@ private injectCustomStyles(): void {
|
|||
*/
|
||||
async openTaskEditModal(task: TaskInfo) {
|
||||
// With native cache, task data is always current - no need to refetch
|
||||
new MinimalistTaskEditModal(this.app, this, { task }).open();
|
||||
new TaskEditModal(this.app, this, { task }).open();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1114,7 +1114,7 @@ private injectCustomStyles(): void {
|
|||
prefilledDetails: details
|
||||
};
|
||||
|
||||
// Convert the TaskConversionOptions to the new format expected by MinimalistTaskCreationModal
|
||||
// Convert the TaskConversionOptions to the new format expected by TaskCreationModal
|
||||
const prePopulatedValues: Partial<TaskInfo> = {};
|
||||
|
||||
if (conversionOptions.parsedData) {
|
||||
|
|
@ -1126,8 +1126,8 @@ private injectCustomStyles(): void {
|
|||
if (conversionOptions.parsedData.recurrence) prePopulatedValues.recurrence = conversionOptions.parsedData.recurrence;
|
||||
}
|
||||
|
||||
// Open MinimalistTaskCreationModal with pre-populated data and conversion callback
|
||||
const modal = new MinimalistTaskCreationModal(this.app, this, {
|
||||
// Open TaskCreationModal with pre-populated data and conversion callback
|
||||
const modal = new TaskCreationModal(this.app, this, {
|
||||
prePopulatedValues,
|
||||
onTaskCreated: (taskInfo: TaskInfo) => {
|
||||
// Handle the task conversion completion - replace the original task line
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,379 +0,0 @@
|
|||
import { App, Notice, setIcon } from 'obsidian';
|
||||
import TaskNotesPlugin from '../main';
|
||||
import { MinimalistTaskModal } from './MinimalistTaskModal';
|
||||
import { TaskInfo, TaskCreationData } from '../types';
|
||||
import { getCurrentTimestamp } from '../utils/dateUtils';
|
||||
import { generateTaskFilename, FilenameContext } from '../utils/filenameGenerator';
|
||||
import { calculateDefaultDate } from '../utils/helpers';
|
||||
import { NaturalLanguageParser, ParsedTaskData as NLParsedTaskData } from '../services/NaturalLanguageParser';
|
||||
|
||||
export interface TaskCreationOptions {
|
||||
prePopulatedValues?: Partial<TaskInfo>;
|
||||
onTaskCreated?: (task: TaskInfo) => void;
|
||||
}
|
||||
|
||||
export class MinimalistTaskCreationModal extends MinimalistTaskModal {
|
||||
private options: TaskCreationOptions;
|
||||
private nlParser: NaturalLanguageParser;
|
||||
private nlInput: HTMLTextAreaElement;
|
||||
private nlPreviewContainer: HTMLElement;
|
||||
private nlButtonContainer: HTMLElement;
|
||||
|
||||
constructor(app: App, plugin: TaskNotesPlugin, options: TaskCreationOptions = {}) {
|
||||
super(app, plugin);
|
||||
this.options = options;
|
||||
this.nlParser = new NaturalLanguageParser(
|
||||
plugin.settings.customStatuses,
|
||||
plugin.settings.customPriorities,
|
||||
plugin.settings.nlpDefaultToScheduled
|
||||
);
|
||||
}
|
||||
|
||||
getModalTitle(): string {
|
||||
return 'Create task';
|
||||
}
|
||||
|
||||
protected createModalContent(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
// Create main container
|
||||
const container = contentEl.createDiv('minimalist-modal-container');
|
||||
|
||||
// Create NLP input as primary interface (if enabled)
|
||||
if (this.plugin.settings.enableNaturalLanguageInput) {
|
||||
this.createNaturalLanguageInput(container);
|
||||
} else {
|
||||
// Fall back to regular title input
|
||||
this.createTitleInput(container);
|
||||
}
|
||||
|
||||
// Create action bar with icons
|
||||
this.createActionBar(container);
|
||||
|
||||
// Create collapsible details section
|
||||
this.createDetailsSection(container);
|
||||
|
||||
// Create save/cancel buttons
|
||||
this.createActionButtons(container);
|
||||
}
|
||||
|
||||
private createNaturalLanguageInput(container: HTMLElement): void {
|
||||
const nlContainer = container.createDiv('nl-input-container');
|
||||
|
||||
// Create minimalist input field
|
||||
this.nlInput = nlContainer.createEl('textarea', {
|
||||
cls: 'nl-input',
|
||||
attr: {
|
||||
placeholder: 'Buy groceries tomorrow at 3pm @home #errands\n\nAdd details here...',
|
||||
rows: '3'
|
||||
}
|
||||
});
|
||||
|
||||
// Preview container
|
||||
this.nlPreviewContainer = nlContainer.createDiv('nl-preview-container');
|
||||
|
||||
// Event listeners
|
||||
this.nlInput.addEventListener('input', () => {
|
||||
const input = this.nlInput.value.trim();
|
||||
if (input) {
|
||||
this.updateNaturalLanguagePreview(input);
|
||||
} else {
|
||||
this.clearNaturalLanguagePreview();
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
this.nlInput.addEventListener('keydown', (e) => {
|
||||
const input = this.nlInput.value.trim();
|
||||
if (!input) return;
|
||||
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.handleSave();
|
||||
} else if (e.key === 'Tab' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.parseAndFillForm(input);
|
||||
}
|
||||
});
|
||||
|
||||
// Focus the input
|
||||
setTimeout(() => {
|
||||
this.nlInput.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
private updateNaturalLanguagePreview(input: string): void {
|
||||
if (!this.nlPreviewContainer) return;
|
||||
|
||||
const parsed = this.nlParser.parseInput(input);
|
||||
const previewData = this.nlParser.getPreviewData(parsed);
|
||||
|
||||
if (previewData.length > 0 && parsed.title) {
|
||||
this.nlPreviewContainer.empty();
|
||||
this.nlPreviewContainer.style.display = 'block';
|
||||
|
||||
previewData.forEach((item) => {
|
||||
const previewItem = this.nlPreviewContainer.createDiv('nl-preview-item');
|
||||
previewItem.textContent = item.text;
|
||||
});
|
||||
} else {
|
||||
this.clearNaturalLanguagePreview();
|
||||
}
|
||||
}
|
||||
|
||||
private clearNaturalLanguagePreview(): void {
|
||||
if (this.nlPreviewContainer) {
|
||||
this.nlPreviewContainer.empty();
|
||||
this.nlPreviewContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
protected createActionBar(container: HTMLElement): void {
|
||||
this.actionBar = container.createDiv('action-bar');
|
||||
|
||||
// NLP-specific icons (only if NLP is enabled)
|
||||
if (this.plugin.settings.enableNaturalLanguageInput) {
|
||||
// Fill form icon
|
||||
this.createActionIcon(this.actionBar, 'wand', 'Fill form from natural language', (icon, event) => {
|
||||
const input = this.nlInput?.value.trim();
|
||||
if (input) {
|
||||
this.parseAndFillForm(input);
|
||||
}
|
||||
});
|
||||
|
||||
// Expand/collapse icon
|
||||
this.createActionIcon(this.actionBar, this.isExpanded ? 'chevron-up' : 'chevron-down',
|
||||
this.isExpanded ? 'Hide detailed options' : 'Show detailed options', (icon, event) => {
|
||||
this.toggleDetailedForm();
|
||||
// Update icon and tooltip
|
||||
const iconEl = icon.querySelector('.icon');
|
||||
if (iconEl) {
|
||||
setIcon(iconEl as HTMLElement, this.isExpanded ? 'chevron-up' : 'chevron-down');
|
||||
}
|
||||
icon.setAttribute('title', this.isExpanded ? 'Hide detailed options' : 'Show detailed options');
|
||||
});
|
||||
|
||||
// Add separator
|
||||
const separator = this.actionBar.createDiv('action-separator');
|
||||
separator.style.width = '1px';
|
||||
separator.style.height = '24px';
|
||||
separator.style.backgroundColor = 'var(--background-modifier-border)';
|
||||
separator.style.margin = '0 var(--size-4-2)';
|
||||
}
|
||||
|
||||
// Due date icon
|
||||
this.createActionIcon(this.actionBar, 'calendar', 'Set due date', (icon, event) => {
|
||||
this.showDateContextMenu(event, 'due');
|
||||
}, 'due-date');
|
||||
|
||||
// Scheduled date icon
|
||||
this.createActionIcon(this.actionBar, 'calendar-clock', 'Set scheduled date', (icon, event) => {
|
||||
this.showDateContextMenu(event, 'scheduled');
|
||||
}, 'scheduled-date');
|
||||
|
||||
// Status icon
|
||||
this.createActionIcon(this.actionBar, 'dot-square', 'Set status', (icon, event) => {
|
||||
this.showStatusContextMenu(event);
|
||||
}, 'status');
|
||||
|
||||
// Priority icon
|
||||
this.createActionIcon(this.actionBar, 'star', 'Set priority', (icon, event) => {
|
||||
this.showPriorityContextMenu(event);
|
||||
}, 'priority');
|
||||
|
||||
// Recurrence icon
|
||||
this.createActionIcon(this.actionBar, 'refresh-ccw', 'Set recurrence', (icon, event) => {
|
||||
this.showRecurrenceContextMenu(event);
|
||||
}, 'recurrence');
|
||||
|
||||
// Update icon states based on current values
|
||||
this.updateIconStates();
|
||||
}
|
||||
|
||||
|
||||
private parseAndFillForm(input: string): void {
|
||||
const parsed = this.nlParser.parseInput(input);
|
||||
this.applyParsedData(parsed);
|
||||
|
||||
// Expand the form to show filled fields
|
||||
if (!this.isExpanded) {
|
||||
this.expandModal();
|
||||
}
|
||||
}
|
||||
|
||||
private applyParsedData(parsed: NLParsedTaskData): void {
|
||||
if (parsed.title) this.title = parsed.title;
|
||||
if (parsed.status) this.status = parsed.status;
|
||||
if (parsed.priority) this.priority = parsed.priority;
|
||||
if (parsed.dueDate) this.dueDate = parsed.dueDate;
|
||||
if (parsed.scheduledDate) this.scheduledDate = parsed.scheduledDate;
|
||||
if (parsed.contexts) this.contexts = parsed.contexts.join(', ');
|
||||
if (parsed.tags) this.tags = parsed.tags.join(', ');
|
||||
if (parsed.details) this.details = parsed.details;
|
||||
if (parsed.recurrence) this.recurrenceRule = parsed.recurrence;
|
||||
|
||||
// Update form inputs if they exist
|
||||
if (this.titleInput) this.titleInput.value = this.title;
|
||||
if (this.detailsInput) this.detailsInput.value = this.details;
|
||||
|
||||
// Update icon states
|
||||
this.updateIconStates();
|
||||
}
|
||||
|
||||
private toggleDetailedForm(): void {
|
||||
if (this.isExpanded) {
|
||||
// Collapse
|
||||
this.isExpanded = false;
|
||||
this.detailsContainer.style.display = 'none';
|
||||
this.containerEl.removeClass('expanded');
|
||||
} else {
|
||||
// Expand
|
||||
this.expandModal();
|
||||
}
|
||||
}
|
||||
|
||||
async initializeFormData(): Promise<void> {
|
||||
// Initialize with default values from settings
|
||||
this.priority = this.plugin.settings.defaultTaskPriority;
|
||||
this.status = this.plugin.settings.defaultTaskStatus;
|
||||
|
||||
// Apply task creation defaults
|
||||
const defaults = this.plugin.settings.taskCreationDefaults;
|
||||
|
||||
// Apply default due date
|
||||
this.dueDate = calculateDefaultDate(defaults.defaultDueDate);
|
||||
|
||||
// Apply default scheduled date based on user settings
|
||||
this.scheduledDate = calculateDefaultDate(defaults.defaultScheduledDate);
|
||||
|
||||
// Apply default contexts and tags
|
||||
this.contexts = defaults.defaultContexts || '';
|
||||
this.tags = defaults.defaultTags || '';
|
||||
|
||||
// Apply default time estimate
|
||||
if (defaults.defaultTimeEstimate && defaults.defaultTimeEstimate > 0) {
|
||||
this.timeEstimate = defaults.defaultTimeEstimate;
|
||||
}
|
||||
|
||||
// Apply pre-populated values if provided (overrides defaults)
|
||||
if (this.options.prePopulatedValues) {
|
||||
this.applyPrePopulatedValues(this.options.prePopulatedValues);
|
||||
}
|
||||
}
|
||||
|
||||
private applyPrePopulatedValues(values: Partial<TaskInfo>): void {
|
||||
if (values.title !== undefined) this.title = values.title;
|
||||
if (values.due !== undefined) this.dueDate = values.due;
|
||||
if (values.scheduled !== undefined) this.scheduledDate = values.scheduled;
|
||||
if (values.priority !== undefined) this.priority = values.priority;
|
||||
if (values.status !== undefined) this.status = values.status;
|
||||
if (values.contexts !== undefined) {
|
||||
this.contexts = values.contexts.join(', ');
|
||||
}
|
||||
if (values.tags !== undefined) {
|
||||
this.tags = values.tags.filter(tag => tag !== this.plugin.settings.taskTag).join(', ');
|
||||
}
|
||||
if (values.timeEstimate !== undefined) this.timeEstimate = values.timeEstimate;
|
||||
if (values.recurrence !== undefined && typeof values.recurrence === 'string') {
|
||||
this.recurrenceRule = values.recurrence;
|
||||
}
|
||||
}
|
||||
|
||||
async handleSave(): Promise<void> {
|
||||
// If NLP is enabled and there's content in the NL field, parse it first
|
||||
if (this.plugin.settings.enableNaturalLanguageInput && this.nlInput) {
|
||||
const nlContent = this.nlInput.value.trim();
|
||||
if (nlContent && !this.title.trim()) {
|
||||
// Only auto-parse if no title has been manually entered
|
||||
const parsed = this.nlParser.parseInput(nlContent);
|
||||
this.applyParsedData(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.validateForm()) {
|
||||
new Notice('Please enter a task title');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const taskData = this.buildTaskData();
|
||||
const result = await this.plugin.taskService.createTask(taskData);
|
||||
|
||||
new Notice(`Task "${result.taskInfo.title}" created successfully`);
|
||||
|
||||
if (this.options.onTaskCreated) {
|
||||
this.options.onTaskCreated(result.taskInfo);
|
||||
}
|
||||
|
||||
this.close();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to create task:', error);
|
||||
new Notice('Failed to create task: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
private buildTaskData(): Partial<TaskInfo> {
|
||||
const now = getCurrentTimestamp();
|
||||
|
||||
// Parse contexts and tags
|
||||
const contextList = this.contexts
|
||||
.split(',')
|
||||
.map(c => c.trim())
|
||||
.filter(c => c.length > 0);
|
||||
|
||||
const tagList = this.tags
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(t => t.length > 0);
|
||||
|
||||
// Add the task tag if it's not already present
|
||||
if (this.plugin.settings.taskTag && !tagList.includes(this.plugin.settings.taskTag)) {
|
||||
tagList.push(this.plugin.settings.taskTag);
|
||||
}
|
||||
|
||||
const taskData: TaskCreationData = {
|
||||
title: this.title.trim(),
|
||||
due: this.dueDate || undefined,
|
||||
scheduled: this.scheduledDate || undefined,
|
||||
priority: this.priority,
|
||||
status: this.status,
|
||||
contexts: contextList.length > 0 ? contextList : undefined,
|
||||
tags: tagList.length > 0 ? tagList : undefined,
|
||||
timeEstimate: this.timeEstimate > 0 ? this.timeEstimate : undefined,
|
||||
recurrence: this.recurrenceRule || undefined,
|
||||
dateCreated: now,
|
||||
dateModified: now
|
||||
};
|
||||
|
||||
// Add details if provided
|
||||
if (this.details.trim()) {
|
||||
// You might want to add the details to the task content or as a separate field
|
||||
// For now, we'll add it as part of the task description
|
||||
taskData.details = this.details.trim();
|
||||
}
|
||||
|
||||
return taskData;
|
||||
}
|
||||
|
||||
private generateFilename(taskData: TaskCreationData): string {
|
||||
const context: FilenameContext = {
|
||||
title: taskData.title || '',
|
||||
status: taskData.status || 'open',
|
||||
priority: taskData.priority || 'normal',
|
||||
dueDate: taskData.due,
|
||||
scheduledDate: taskData.scheduled
|
||||
};
|
||||
|
||||
return generateTaskFilename(context, this.plugin.settings);
|
||||
}
|
||||
|
||||
// Override to prevent creating duplicate title input when NLP is enabled
|
||||
protected createTitleInput(container: HTMLElement): void {
|
||||
// Only create title input if NLP is disabled
|
||||
if (!this.plugin.settings.enableNaturalLanguageInput) {
|
||||
super.createTitleInput(container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
import { App, Notice } from 'obsidian';
|
||||
import TaskNotesPlugin from '../main';
|
||||
import { MinimalistTaskModal } from './MinimalistTaskModal';
|
||||
import { TaskInfo } from '../types';
|
||||
import { getCurrentTimestamp } from '../utils/dateUtils';
|
||||
import { formatTimestampForDisplay } from '../utils/dateUtils';
|
||||
|
||||
export interface TaskEditOptions {
|
||||
task: TaskInfo;
|
||||
onTaskUpdated?: (task: TaskInfo) => void;
|
||||
}
|
||||
|
||||
export class MinimalistTaskEditModal extends MinimalistTaskModal {
|
||||
private task: TaskInfo;
|
||||
private options: TaskEditOptions;
|
||||
private metadataContainer: HTMLElement;
|
||||
|
||||
constructor(app: App, plugin: TaskNotesPlugin, options: TaskEditOptions) {
|
||||
super(app, plugin);
|
||||
this.task = options.task;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
getModalTitle(): string {
|
||||
return 'Edit task';
|
||||
}
|
||||
|
||||
async initializeFormData(): Promise<void> {
|
||||
// Initialize form fields with current task data
|
||||
this.title = this.task.title;
|
||||
this.dueDate = this.task.due || '';
|
||||
this.scheduledDate = this.task.scheduled || '';
|
||||
this.priority = this.task.priority;
|
||||
this.status = this.task.status;
|
||||
this.contexts = this.task.contexts ? this.task.contexts.join(', ') : '';
|
||||
this.tags = this.task.tags
|
||||
? this.task.tags.filter(tag => tag !== this.plugin.settings.taskTag).join(', ')
|
||||
: '';
|
||||
this.timeEstimate = this.task.timeEstimate || 0;
|
||||
|
||||
// Handle recurrence - support both new rrule strings and old RecurrenceInfo objects
|
||||
if (this.task.recurrence) {
|
||||
if (typeof this.task.recurrence === 'string') {
|
||||
this.recurrenceRule = this.task.recurrence;
|
||||
} else if (typeof this.task.recurrence === 'object' && this.task.recurrence.frequency) {
|
||||
// Legacy recurrence object - convert to string representation for display
|
||||
this.recurrenceRule = this.convertLegacyRecurrenceToString(this.task.recurrence);
|
||||
}
|
||||
} else {
|
||||
this.recurrenceRule = '';
|
||||
}
|
||||
}
|
||||
|
||||
private convertLegacyRecurrenceToString(recurrence: any): string {
|
||||
// Convert legacy recurrence object to a readable string
|
||||
// This is for display purposes in the edit modal
|
||||
if (!recurrence.frequency) return '';
|
||||
|
||||
let recurrenceText = recurrence.frequency;
|
||||
|
||||
if (recurrence.frequency === 'weekly' && recurrence.days_of_week) {
|
||||
recurrenceText += ` on ${recurrence.days_of_week.join(', ')}`;
|
||||
}
|
||||
|
||||
if (recurrence.frequency === 'monthly' && recurrence.day_of_month) {
|
||||
recurrenceText += ` on day ${recurrence.day_of_month}`;
|
||||
}
|
||||
|
||||
return recurrenceText;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
this.containerEl.addClass('tasknotes-plugin', 'minimalist-task-modal');
|
||||
this.titleEl.textContent = this.getModalTitle();
|
||||
|
||||
this.initializeFormData().then(() => {
|
||||
this.createModalContent();
|
||||
// Update icon states after creating the action bar
|
||||
this.updateIconStates();
|
||||
this.focusTitleInput();
|
||||
});
|
||||
}
|
||||
|
||||
protected createModalContent(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
// Create main container
|
||||
const container = contentEl.createDiv('minimalist-modal-container');
|
||||
|
||||
// Create action bar with icons
|
||||
this.createActionBar(container);
|
||||
|
||||
// Create expanded details section (always expanded for editing)
|
||||
this.createDetailsSection(container);
|
||||
|
||||
// Create metadata section (for edit modal)
|
||||
this.createMetadataSection(container);
|
||||
|
||||
// Create save/cancel buttons
|
||||
this.createActionButtons(container);
|
||||
}
|
||||
|
||||
|
||||
private createMetadataSection(container: HTMLElement): void {
|
||||
this.metadataContainer = container.createDiv('metadata-container');
|
||||
|
||||
const metadataLabel = this.metadataContainer.createDiv('detail-label');
|
||||
metadataLabel.textContent = 'Task Information';
|
||||
|
||||
const metadataContent = this.metadataContainer.createDiv('metadata-content');
|
||||
|
||||
// Created date
|
||||
if (this.task.dateCreated) {
|
||||
const createdDiv = metadataContent.createDiv('metadata-item');
|
||||
createdDiv.createSpan('metadata-key').textContent = 'Created: ';
|
||||
createdDiv.createSpan('metadata-value').textContent = formatTimestampForDisplay(this.task.dateCreated);
|
||||
}
|
||||
|
||||
// Modified date
|
||||
if (this.task.dateModified) {
|
||||
const modifiedDiv = metadataContent.createDiv('metadata-item');
|
||||
modifiedDiv.createSpan('metadata-key').textContent = 'Modified: ';
|
||||
modifiedDiv.createSpan('metadata-value').textContent = formatTimestampForDisplay(this.task.dateModified);
|
||||
}
|
||||
|
||||
// File path (if available)
|
||||
if (this.task.path) {
|
||||
const pathDiv = metadataContent.createDiv('metadata-item');
|
||||
pathDiv.createSpan('metadata-key').textContent = 'File: ';
|
||||
pathDiv.createSpan('metadata-value').textContent = this.task.path;
|
||||
}
|
||||
}
|
||||
|
||||
async handleSave(): Promise<void> {
|
||||
if (!this.validateForm()) {
|
||||
new Notice('Please enter a task title');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const changes = this.getChanges();
|
||||
|
||||
if (Object.keys(changes).length === 0) {
|
||||
new Notice('No changes to save');
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTask = await this.plugin.taskService.updateTask(this.task, changes);
|
||||
|
||||
new Notice(`Task "${updatedTask.title}" updated successfully`);
|
||||
|
||||
if (this.options.onTaskUpdated) {
|
||||
this.options.onTaskUpdated(updatedTask);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to update task:', error);
|
||||
new Notice('Failed to update task: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
private getChanges(): Partial<TaskInfo> {
|
||||
const changes: Partial<TaskInfo> = {};
|
||||
|
||||
// Check for changes and only include modified fields
|
||||
if (this.title.trim() !== this.task.title) {
|
||||
changes.title = this.title.trim();
|
||||
}
|
||||
|
||||
if (this.dueDate !== (this.task.due || '')) {
|
||||
changes.due = this.dueDate || undefined;
|
||||
}
|
||||
|
||||
if (this.scheduledDate !== (this.task.scheduled || '')) {
|
||||
changes.scheduled = this.scheduledDate || undefined;
|
||||
}
|
||||
|
||||
if (this.priority !== this.task.priority) {
|
||||
changes.priority = this.priority;
|
||||
}
|
||||
|
||||
if (this.status !== this.task.status) {
|
||||
changes.status = this.status;
|
||||
}
|
||||
|
||||
// Parse and compare contexts
|
||||
const newContexts = this.contexts
|
||||
.split(',')
|
||||
.map(c => c.trim())
|
||||
.filter(c => c.length > 0);
|
||||
const oldContexts = this.task.contexts || [];
|
||||
|
||||
if (JSON.stringify(newContexts.sort()) !== JSON.stringify(oldContexts.sort())) {
|
||||
changes.contexts = newContexts.length > 0 ? newContexts : undefined;
|
||||
}
|
||||
|
||||
// Parse and compare tags
|
||||
const newTags = this.tags
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(t => t.length > 0);
|
||||
|
||||
// Add the task tag if it's not already present
|
||||
if (this.plugin.settings.taskTag && !newTags.includes(this.plugin.settings.taskTag)) {
|
||||
newTags.push(this.plugin.settings.taskTag);
|
||||
}
|
||||
|
||||
const oldTags = this.task.tags || [];
|
||||
|
||||
if (JSON.stringify(newTags.sort()) !== JSON.stringify(oldTags.sort())) {
|
||||
changes.tags = newTags.length > 0 ? newTags : undefined;
|
||||
}
|
||||
|
||||
// Compare time estimate
|
||||
const newTimeEstimate = this.timeEstimate > 0 ? this.timeEstimate : undefined;
|
||||
const oldTimeEstimate = this.task.timeEstimate;
|
||||
|
||||
if (newTimeEstimate !== oldTimeEstimate) {
|
||||
changes.timeEstimate = newTimeEstimate;
|
||||
}
|
||||
|
||||
// Compare recurrence
|
||||
const oldRecurrence = typeof this.task.recurrence === 'string'
|
||||
? this.task.recurrence
|
||||
: '';
|
||||
|
||||
if (this.recurrenceRule !== oldRecurrence) {
|
||||
changes.recurrence = this.recurrenceRule || undefined;
|
||||
}
|
||||
|
||||
// Always update modified timestamp if there are changes
|
||||
if (Object.keys(changes).length > 0) {
|
||||
changes.dateModified = getCurrentTimestamp();
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
|
||||
private async openTaskNote(): Promise<void> {
|
||||
try {
|
||||
// Get the file from the task path
|
||||
const file = this.app.vault.getAbstractFileByPath(this.task.path);
|
||||
|
||||
if (!file) {
|
||||
new Notice(`Could not find task file: ${this.task.path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Open the file in a new leaf
|
||||
const leaf = this.app.workspace.getLeaf(true);
|
||||
await leaf.openFile(file as any);
|
||||
|
||||
// Close the modal
|
||||
this.close();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to open task note:', error);
|
||||
new Notice('Failed to open task note');
|
||||
}
|
||||
}
|
||||
|
||||
// Start expanded for edit modal - override parent property
|
||||
protected isExpanded = true;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,379 +1,267 @@
|
|||
import { App, Notice, TFile, Setting } from 'obsidian';
|
||||
import { RRule } from 'rrule';
|
||||
import { App, Notice } from 'obsidian';
|
||||
import TaskNotesPlugin from '../main';
|
||||
import { BaseTaskModal } from './BaseTaskModal';
|
||||
import { TaskModal } from './TaskModal';
|
||||
import { TaskInfo } from '../types';
|
||||
import { getCurrentTimestamp } from '../utils/dateUtils';
|
||||
import { formatTimestampForDisplay } from '../utils/dateUtils';
|
||||
|
||||
export class TaskEditModal extends BaseTaskModal {
|
||||
export interface TaskEditOptions {
|
||||
task: TaskInfo;
|
||||
onTaskUpdated?: (task: TaskInfo) => void;
|
||||
}
|
||||
|
||||
constructor(app: App, plugin: TaskNotesPlugin, task: TaskInfo) {
|
||||
export class TaskEditModal extends TaskModal {
|
||||
private task: TaskInfo;
|
||||
private options: TaskEditOptions;
|
||||
private metadataContainer: HTMLElement;
|
||||
|
||||
constructor(app: App, plugin: TaskNotesPlugin, options: TaskEditOptions) {
|
||||
super(app, plugin);
|
||||
this.task = task;
|
||||
this.task = options.task;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
protected async initializeFormData(): Promise<void> {
|
||||
// With native cache, task data is always current - no need to refetch
|
||||
getModalTitle(): string {
|
||||
return 'Edit task';
|
||||
}
|
||||
|
||||
async initializeFormData(): Promise<void> {
|
||||
// Initialize form fields with current task data
|
||||
this.title = this.task.title;
|
||||
// Initialize date and time components properly
|
||||
this.dueDate = this.task.due || '';
|
||||
this.scheduledDate = this.task.scheduled || '';
|
||||
// Time components will be handled by the input fields automatically
|
||||
|
||||
this.priority = this.task.priority;
|
||||
this.status = this.task.status;
|
||||
this.contexts = this.task.contexts ? this.task.contexts.join(', ') : '';
|
||||
this.tags = this.task.tags ? this.task.tags.filter(tag => tag !== this.plugin.settings.taskTag).join(', ') : '';
|
||||
// Preserve the original time estimate value, ensuring it's not reset to 0
|
||||
this.timeEstimate = this.task.timeEstimate !== undefined ? this.task.timeEstimate : 0;
|
||||
this.tags = this.task.tags
|
||||
? this.task.tags.filter(tag => tag !== this.plugin.settings.taskTag).join(', ')
|
||||
: '';
|
||||
this.timeEstimate = this.task.timeEstimate || 0;
|
||||
|
||||
// Handle recurrence - support both new rrule strings and old RecurrenceInfo objects
|
||||
if (this.task.recurrence) {
|
||||
if (typeof this.task.recurrence === 'string') {
|
||||
// New rrule string format
|
||||
this.recurrenceRule = this.task.recurrence;
|
||||
this.parseRRuleString(this.task.recurrence);
|
||||
} else if (typeof this.task.recurrence === 'object' && this.task.recurrence.frequency) {
|
||||
// Legacy RecurrenceInfo object - convert to rrule
|
||||
this.convertLegacyRecurrenceToRRule(this.task.recurrence);
|
||||
// Legacy recurrence object - convert to string representation for display
|
||||
this.recurrenceRule = this.convertLegacyRecurrenceToString(this.task.recurrence);
|
||||
}
|
||||
} else {
|
||||
// No recurrence
|
||||
this.frequencyMode = 'NONE';
|
||||
this.recurrenceRule = '';
|
||||
}
|
||||
}
|
||||
|
||||
private convertLegacyRecurrenceToRRule(recurrence: any): void {
|
||||
try {
|
||||
// Map legacy frequency to new format
|
||||
switch (recurrence.frequency) {
|
||||
case 'daily':
|
||||
this.frequencyMode = 'DAILY';
|
||||
this.rruleInterval = 1;
|
||||
break;
|
||||
case 'weekly':
|
||||
this.frequencyMode = 'WEEKLY';
|
||||
this.rruleInterval = 1;
|
||||
if (recurrence.days_of_week && recurrence.days_of_week.length > 0) {
|
||||
// Convert legacy day abbreviations to RRule weekdays
|
||||
const dayMap: Record<string, any> = {
|
||||
'mon': RRule.MO,
|
||||
'tue': RRule.TU,
|
||||
'wed': RRule.WE,
|
||||
'thu': RRule.TH,
|
||||
'fri': RRule.FR,
|
||||
'sat': RRule.SA,
|
||||
'sun': RRule.SU
|
||||
};
|
||||
this.rruleByWeekday = recurrence.days_of_week
|
||||
.map((day: string) => dayMap[day.toLowerCase()])
|
||||
.filter((wd: any) => wd);
|
||||
}
|
||||
break;
|
||||
case 'monthly':
|
||||
this.frequencyMode = 'MONTHLY';
|
||||
this.rruleInterval = 1;
|
||||
this.monthlyMode = 'day';
|
||||
if (recurrence.day_of_month) {
|
||||
this.rruleByMonthday = [recurrence.day_of_month];
|
||||
}
|
||||
break;
|
||||
case 'yearly':
|
||||
this.frequencyMode = 'YEARLY';
|
||||
this.rruleInterval = 1;
|
||||
if (recurrence.month_of_year) {
|
||||
this.rruleByMonth = [recurrence.month_of_year];
|
||||
}
|
||||
if (recurrence.day_of_month) {
|
||||
this.rruleByMonthday = [recurrence.day_of_month];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
this.frequencyMode = 'NONE';
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate the rrule string from the converted data
|
||||
this.recurrenceRule = this.generateRRuleString();
|
||||
} catch (error) {
|
||||
console.error('Error converting legacy recurrence to rrule:', error);
|
||||
this.frequencyMode = 'NONE';
|
||||
this.recurrenceRule = '';
|
||||
private convertLegacyRecurrenceToString(recurrence: any): string {
|
||||
// Convert legacy recurrence object to a readable string
|
||||
// This is for display purposes in the edit modal
|
||||
if (!recurrence.frequency) return '';
|
||||
|
||||
let recurrenceText = recurrence.frequency;
|
||||
|
||||
if (recurrence.frequency === 'weekly' && recurrence.days_of_week) {
|
||||
recurrenceText += ` on ${recurrence.days_of_week.join(', ')}`;
|
||||
}
|
||||
|
||||
if (recurrence.frequency === 'monthly' && recurrence.day_of_month) {
|
||||
recurrenceText += ` on day ${recurrence.day_of_month}`;
|
||||
}
|
||||
|
||||
return recurrenceText;
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
onOpen() {
|
||||
this.containerEl.addClass('tasknotes-plugin', 'minimalist-task-modal');
|
||||
this.titleEl.textContent = this.getModalTitle();
|
||||
|
||||
this.initializeFormData().then(() => {
|
||||
this.createModalContent();
|
||||
// Update icon states after creating the action bar
|
||||
this.updateIconStates();
|
||||
this.focusTitleInput();
|
||||
});
|
||||
}
|
||||
|
||||
protected createModalContent(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.addClass('tasknotes-plugin', 'task-edit-modal');
|
||||
new Setting(contentEl)
|
||||
.setName('Edit task')
|
||||
.setHeading();
|
||||
contentEl.empty();
|
||||
|
||||
// Initialize form data and cache autocomplete data
|
||||
await this.initializeFormData();
|
||||
this.existingContexts = await this.getExistingContexts();
|
||||
this.existingTags = await this.getExistingTags();
|
||||
// Create main container
|
||||
const container = contentEl.createDiv('minimalist-modal-container');
|
||||
|
||||
// Title with character count
|
||||
this.createFormGroup(contentEl, 'Title', (container) => {
|
||||
this.createTitleInputWithCounter(container, 200);
|
||||
|
||||
// Auto-focus on the title field for immediate editing
|
||||
const input = container.querySelector('input');
|
||||
if (input) {
|
||||
window.setTimeout(() => input.focus(), 50);
|
||||
}
|
||||
});
|
||||
// Create action bar with icons
|
||||
this.createActionBar(container);
|
||||
|
||||
// Due Date
|
||||
this.createFormGroup(contentEl, 'Due date', (container) => {
|
||||
this.createDueDateInput(container);
|
||||
});
|
||||
// Create expanded details section (always expanded for editing)
|
||||
this.createDetailsSection(container);
|
||||
|
||||
// Scheduled Date
|
||||
this.createFormGroup(contentEl, 'Scheduled date', (container) => {
|
||||
this.createScheduledDateInput(container);
|
||||
});
|
||||
// Create metadata section (for edit modal)
|
||||
this.createMetadataSection(container);
|
||||
|
||||
// Priority
|
||||
this.createFormGroup(contentEl, 'Priority', (container) => {
|
||||
this.createPriorityDropdown(container);
|
||||
});
|
||||
|
||||
// Status
|
||||
this.createFormGroup(contentEl, 'Status', (container) => {
|
||||
this.createStatusDropdown(container);
|
||||
});
|
||||
|
||||
// Contexts with autocomplete
|
||||
this.createFormGroup(contentEl, 'Contexts', (container) => {
|
||||
this.createAutocompleteInput(
|
||||
container,
|
||||
'contexts',
|
||||
() => this.existingContexts,
|
||||
(value) => { this.contexts = value; }
|
||||
);
|
||||
});
|
||||
|
||||
// Tags with autocomplete
|
||||
this.createFormGroup(contentEl, 'Tags', (container) => {
|
||||
this.createAutocompleteInput(
|
||||
container,
|
||||
'tags',
|
||||
() => this.existingTags,
|
||||
(value) => { this.tags = value; }
|
||||
);
|
||||
});
|
||||
|
||||
// Time Estimate
|
||||
this.createFormGroup(contentEl, 'Time estimate', (container) => {
|
||||
this.createTimeEstimateInput(container);
|
||||
});
|
||||
|
||||
// Recurrence
|
||||
this.createFormGroup(contentEl, 'Recurrence', (container) => {
|
||||
this.createRRuleBuilder(container);
|
||||
});
|
||||
|
||||
// Metadata footer
|
||||
this.createMetadataFooter(contentEl);
|
||||
|
||||
// Action buttons
|
||||
this.createActionButtons(contentEl);
|
||||
|
||||
// Keyboard shortcuts
|
||||
contentEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.close();
|
||||
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.saveTask();
|
||||
}
|
||||
});
|
||||
// Create save/cancel buttons
|
||||
this.createActionButtons(container);
|
||||
}
|
||||
|
||||
protected createActionButtons(container: HTMLElement): void {
|
||||
const buttonContainer = container.createDiv({ cls: 'modal-form__buttons' });
|
||||
|
||||
// Open Note button
|
||||
const openButton = buttonContainer.createEl('button', {
|
||||
text: 'Open note',
|
||||
cls: 'modal-form__button modal-form__button--tertiary'
|
||||
});
|
||||
openButton.addEventListener('click', () => {
|
||||
this.openNote();
|
||||
});
|
||||
|
||||
// Save button
|
||||
const saveButton = buttonContainer.createEl('button', {
|
||||
text: 'Save',
|
||||
cls: 'modal-form__button modal-form__button--primary'
|
||||
});
|
||||
saveButton.addEventListener('click', () => {
|
||||
this.saveTask();
|
||||
});
|
||||
private createMetadataSection(container: HTMLElement): void {
|
||||
this.metadataContainer = container.createDiv('metadata-container');
|
||||
|
||||
// Cancel button
|
||||
const cancelButton = buttonContainer.createEl('button', {
|
||||
text: 'Cancel',
|
||||
cls: 'modal-form__button modal-form__button--secondary'
|
||||
});
|
||||
cancelButton.addEventListener('click', () => {
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
protected async handleSubmit(): Promise<void> {
|
||||
await this.saveTask();
|
||||
}
|
||||
|
||||
private createMetadataFooter(container: HTMLElement): void {
|
||||
const footer = container.createDiv({ cls: 'task-edit-modal__metadata' });
|
||||
const metadataLabel = this.metadataContainer.createDiv('detail-label');
|
||||
metadataLabel.textContent = 'Task Information';
|
||||
|
||||
const metadataContainer = footer.createDiv({ cls: 'task-edit-modal__metadata-container' });
|
||||
const metadataContent = this.metadataContainer.createDiv('metadata-content');
|
||||
|
||||
// Created date
|
||||
if (this.task.dateCreated) {
|
||||
metadataContainer.createDiv({
|
||||
cls: 'task-edit-modal__metadata-item',
|
||||
text: `Created: ${formatTimestampForDisplay(this.task.dateCreated, 'MMM d, yyyy \'at\' h:mm a')}`
|
||||
});
|
||||
const createdDiv = metadataContent.createDiv('metadata-item');
|
||||
createdDiv.createSpan('metadata-key').textContent = 'Created: ';
|
||||
createdDiv.createSpan('metadata-value').textContent = formatTimestampForDisplay(this.task.dateCreated);
|
||||
}
|
||||
|
||||
// Modified date
|
||||
if (this.task.dateModified) {
|
||||
metadataContainer.createDiv({
|
||||
cls: 'task-edit-modal__metadata-item',
|
||||
text: `Modified: ${formatTimestampForDisplay(this.task.dateModified, 'MMM d, yyyy \'at\' h:mm a')}`
|
||||
});
|
||||
const modifiedDiv = metadataContent.createDiv('metadata-item');
|
||||
modifiedDiv.createSpan('metadata-key').textContent = 'Modified: ';
|
||||
modifiedDiv.createSpan('metadata-value').textContent = formatTimestampForDisplay(this.task.dateModified);
|
||||
}
|
||||
|
||||
// File path (if available)
|
||||
if (this.task.path) {
|
||||
const pathDiv = metadataContent.createDiv('metadata-item');
|
||||
pathDiv.createSpan('metadata-key').textContent = 'File: ';
|
||||
pathDiv.createSpan('metadata-value').textContent = this.task.path;
|
||||
}
|
||||
}
|
||||
|
||||
private openNote(): void {
|
||||
const file = this.app.vault.getAbstractFileByPath(this.task.path);
|
||||
if (file instanceof TFile) {
|
||||
this.app.workspace.getLeaf(false).openFile(file);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
async saveTask() {
|
||||
// Validate required fields
|
||||
if (!this.title || !this.title.trim()) {
|
||||
new Notice('Title is required');
|
||||
async handleSave(): Promise<void> {
|
||||
if (!this.validateForm()) {
|
||||
new Notice('Please enter a task title');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.title.length > 200) {
|
||||
new Notice('Title is too long (max 200 characters)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate recurrence fields
|
||||
if (this.frequencyMode === 'WEEKLY' && this.rruleByWeekday.length === 0) {
|
||||
new Notice('Please select at least one day for weekly recurrence');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.frequencyMode === 'MONTHLY') {
|
||||
if (this.monthlyMode === 'day' && this.rruleByMonthday.length === 0) {
|
||||
new Notice('Please specify a day for monthly recurrence');
|
||||
return;
|
||||
}
|
||||
if (this.monthlyMode === 'weekday' && (this.rruleByWeekday.length === 0 || this.rruleBySetpos.length === 0)) {
|
||||
new Notice('Please specify both position and weekday for monthly recurrence');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.frequencyMode === 'YEARLY') {
|
||||
if (this.rruleByMonth.length === 0 || this.rruleByMonthday.length === 0) {
|
||||
new Notice('Please specify both month and day for yearly recurrence');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Prepare contexts and tags arrays
|
||||
const contextsArray = this.contexts ? this.contexts.split(',').map(c => c.trim()).filter(c => c) : [];
|
||||
const tagsArray = this.tags ? this.tags.split(',').map(t => t.trim()).filter(t => t) : [];
|
||||
const changes = this.getChanges();
|
||||
|
||||
// Add task tag if not present
|
||||
if (!tagsArray.includes(this.plugin.settings.taskTag)) {
|
||||
tagsArray.unshift(this.plugin.settings.taskTag);
|
||||
}
|
||||
|
||||
// Detect changes by comparing current form values with original task
|
||||
const updates: Partial<TaskInfo> = {};
|
||||
|
||||
// Check for changes in simple fields
|
||||
if (this.title !== this.task.title) {
|
||||
updates.title = this.title;
|
||||
}
|
||||
if (this.priority !== this.task.priority) {
|
||||
updates.priority = this.priority;
|
||||
}
|
||||
if (this.status !== this.task.status) {
|
||||
updates.status = this.status;
|
||||
}
|
||||
// Check for changes in date fields (compare full datetime values)
|
||||
const currentDueDate = this.dueDate || '';
|
||||
const originalDueDate = this.task.due || '';
|
||||
if (currentDueDate !== originalDueDate) {
|
||||
updates.due = this.dueDate || undefined;
|
||||
}
|
||||
|
||||
const currentScheduledDate = this.scheduledDate || '';
|
||||
const originalScheduledDate = this.task.scheduled || '';
|
||||
if (currentScheduledDate !== originalScheduledDate) {
|
||||
updates.scheduled = this.scheduledDate || undefined;
|
||||
}
|
||||
|
||||
// Check time estimate with proper handling of undefined vs 0
|
||||
const originalTimeEstimate = this.task.timeEstimate !== undefined ? this.task.timeEstimate : 0;
|
||||
if (this.timeEstimate !== originalTimeEstimate) {
|
||||
updates.timeEstimate = this.timeEstimate > 0 ? this.timeEstimate : undefined;
|
||||
}
|
||||
|
||||
// Check for changes in contexts array
|
||||
const originalContexts = this.task.contexts || [];
|
||||
const arraysEqual = (a: string[], b: string[]) => a.length === b.length && a.every((val, index) => val === b[index]);
|
||||
if (!arraysEqual(contextsArray, originalContexts)) {
|
||||
updates.contexts = contextsArray.length > 0 ? contextsArray : undefined;
|
||||
}
|
||||
|
||||
// Check for changes in tags array
|
||||
const originalTags = this.task.tags || [];
|
||||
if (!arraysEqual(tagsArray, originalTags)) {
|
||||
updates.tags = tagsArray;
|
||||
}
|
||||
|
||||
// Check for changes in recurrence
|
||||
const currentRecurrenceRule = this.recurrenceRule || '';
|
||||
const originalRecurrenceRule = typeof this.task.recurrence === 'string'
|
||||
? this.task.recurrence
|
||||
: '';
|
||||
|
||||
if (currentRecurrenceRule !== originalRecurrenceRule) {
|
||||
updates.recurrence = currentRecurrenceRule || undefined;
|
||||
}
|
||||
|
||||
// If no changes detected, show message and return
|
||||
if (Object.keys(updates).length === 0) {
|
||||
new Notice('No changes detected');
|
||||
if (Object.keys(changes).length === 0) {
|
||||
new Notice('No changes to save');
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the centralized update service with current task state
|
||||
await this.plugin.taskService.updateTask(this.task, updates);
|
||||
const updatedTask = await this.plugin.taskService.updateTask(this.task, changes);
|
||||
|
||||
new Notice('Task updated successfully');
|
||||
this.close();
|
||||
new Notice(`Task "${updatedTask.title}" updated successfully`);
|
||||
|
||||
if (this.options.onTaskUpdated) {
|
||||
this.options.onTaskUpdated(updatedTask);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to save task:', error);
|
||||
new Notice('Failed to save task. Please try again.');
|
||||
console.error('Failed to update task:', error);
|
||||
new Notice('Failed to update task: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
private getChanges(): Partial<TaskInfo> {
|
||||
const changes: Partial<TaskInfo> = {};
|
||||
|
||||
// Check for changes and only include modified fields
|
||||
if (this.title.trim() !== this.task.title) {
|
||||
changes.title = this.title.trim();
|
||||
}
|
||||
|
||||
if (this.dueDate !== (this.task.due || '')) {
|
||||
changes.due = this.dueDate || undefined;
|
||||
}
|
||||
|
||||
if (this.scheduledDate !== (this.task.scheduled || '')) {
|
||||
changes.scheduled = this.scheduledDate || undefined;
|
||||
}
|
||||
|
||||
if (this.priority !== this.task.priority) {
|
||||
changes.priority = this.priority;
|
||||
}
|
||||
|
||||
if (this.status !== this.task.status) {
|
||||
changes.status = this.status;
|
||||
}
|
||||
|
||||
// Parse and compare contexts
|
||||
const newContexts = this.contexts
|
||||
.split(',')
|
||||
.map(c => c.trim())
|
||||
.filter(c => c.length > 0);
|
||||
const oldContexts = this.task.contexts || [];
|
||||
|
||||
if (JSON.stringify(newContexts.sort()) !== JSON.stringify(oldContexts.sort())) {
|
||||
changes.contexts = newContexts.length > 0 ? newContexts : undefined;
|
||||
}
|
||||
|
||||
// Parse and compare tags
|
||||
const newTags = this.tags
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(t => t.length > 0);
|
||||
|
||||
// Add the task tag if it's not already present
|
||||
if (this.plugin.settings.taskTag && !newTags.includes(this.plugin.settings.taskTag)) {
|
||||
newTags.push(this.plugin.settings.taskTag);
|
||||
}
|
||||
|
||||
const oldTags = this.task.tags || [];
|
||||
|
||||
if (JSON.stringify(newTags.sort()) !== JSON.stringify(oldTags.sort())) {
|
||||
changes.tags = newTags.length > 0 ? newTags : undefined;
|
||||
}
|
||||
|
||||
// Compare time estimate
|
||||
const newTimeEstimate = this.timeEstimate > 0 ? this.timeEstimate : undefined;
|
||||
const oldTimeEstimate = this.task.timeEstimate;
|
||||
|
||||
if (newTimeEstimate !== oldTimeEstimate) {
|
||||
changes.timeEstimate = newTimeEstimate;
|
||||
}
|
||||
|
||||
// Compare recurrence
|
||||
const oldRecurrence = typeof this.task.recurrence === 'string'
|
||||
? this.task.recurrence
|
||||
: '';
|
||||
|
||||
if (this.recurrenceRule !== oldRecurrence) {
|
||||
changes.recurrence = this.recurrenceRule || undefined;
|
||||
}
|
||||
|
||||
// Always update modified timestamp if there are changes
|
||||
if (Object.keys(changes).length > 0) {
|
||||
changes.dateModified = getCurrentTimestamp();
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
|
||||
private async openTaskNote(): Promise<void> {
|
||||
try {
|
||||
// Get the file from the task path
|
||||
const file = this.app.vault.getAbstractFileByPath(this.task.path);
|
||||
|
||||
if (!file) {
|
||||
new Notice(`Could not find task file: ${this.task.path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Open the file in a new leaf
|
||||
const leaf = this.app.workspace.getLeaf(true);
|
||||
await leaf.openFile(file as any);
|
||||
|
||||
// Close the modal
|
||||
this.close();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to open task note:', error);
|
||||
new Notice('Failed to open task note');
|
||||
}
|
||||
}
|
||||
|
||||
// Start expanded for edit modal - override parent property
|
||||
protected isExpanded = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { StatusContextMenu } from '../components/StatusContextMenu';
|
|||
import { RecurrenceContextMenu } from '../components/RecurrenceContextMenu';
|
||||
import { getDatePart, getTimePart, combineDateAndTime } from '../utils/dateUtils';
|
||||
|
||||
export abstract class MinimalistTaskModal extends Modal {
|
||||
export abstract class TaskModal extends Modal {
|
||||
plugin: TaskNotesPlugin;
|
||||
|
||||
// Core task properties
|
||||
16
src/types/taskConversion.ts
Normal file
16
src/types/taskConversion.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Editor } from 'obsidian';
|
||||
import { ParsedTaskData } from '../utils/TasksPluginParser';
|
||||
|
||||
export interface TaskConversionOptions {
|
||||
parsedData?: ParsedTaskData;
|
||||
editor?: Editor;
|
||||
lineNumber?: number;
|
||||
selectionInfo?: {
|
||||
taskLine: string;
|
||||
details: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
originalContent: string[]
|
||||
};
|
||||
prefilledDetails?: string;
|
||||
}
|
||||
|
|
@ -25,8 +25,8 @@ import {
|
|||
CalendarViewPreferences,
|
||||
ICSEvent
|
||||
} from '../types';
|
||||
import { MinimalistTaskCreationModal } from '../modals/MinimalistTaskCreationModal';
|
||||
import { MinimalistTaskEditModal } from '../modals/MinimalistTaskEditModal';
|
||||
import { TaskCreationModal } from '../modals/TaskCreationModal';
|
||||
import { TaskEditModal } from '../modals/TaskEditModal';
|
||||
import { UnscheduledTasksSelectorModal, ScheduleTaskOptions } from '../modals/UnscheduledTasksSelectorModal';
|
||||
import { TimeblockCreationModal } from '../modals/TimeblockCreationModal';
|
||||
import { FilterBar } from '../ui/FilterBar';
|
||||
|
|
@ -827,7 +827,7 @@ export class AdvancedCalendarView extends ItemView {
|
|||
? 60 // Default 1 hour for all-day events
|
||||
: Math.round((end.getTime() - start.getTime()) / (1000 * 60)); // Duration in minutes
|
||||
|
||||
const modal = new MinimalistTaskCreationModal(this.app, this.plugin, {
|
||||
const modal = new TaskCreationModal(this.app, this.plugin, {
|
||||
prePopulatedValues: {
|
||||
scheduled: scheduledDate,
|
||||
timeEstimate: timeEstimate > 0 ? timeEstimate : 60
|
||||
|
|
@ -979,7 +979,7 @@ export class AdvancedCalendarView extends ItemView {
|
|||
}
|
||||
} else if (jsEvent.button === 0) {
|
||||
// Left click only: Open edit modal
|
||||
const editModal = new MinimalistTaskEditModal(this.app, this.plugin, { task: taskInfo });
|
||||
const editModal = new TaskEditModal(this.app, this.plugin, { task: taskInfo });
|
||||
editModal.open();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,8 +117,6 @@
|
|||
BASE MODAL CONTAINER STYLES
|
||||
===================================================================== */
|
||||
|
||||
.tasknotes-plugin .task-creation-modal,
|
||||
.tasknotes-plugin .task-edit-modal,
|
||||
.tasknotes-plugin .due-date-modal,
|
||||
.tasknotes-plugin .scheduled-date-modal,
|
||||
.tasknotes-plugin .task-selector-modal {
|
||||
|
|
@ -134,8 +132,6 @@
|
|||
MODAL HEADERS AND HEADINGS
|
||||
===================================================================== */
|
||||
|
||||
.tasknotes-plugin .task-creation-modal .setting-item-heading,
|
||||
.tasknotes-plugin .task-edit-modal .setting-item-heading,
|
||||
.tasknotes-plugin .due-date-modal .setting-item-heading,
|
||||
.tasknotes-plugin .scheduled-date-modal .setting-item-heading {
|
||||
font-size: var(--tn-font-size-xl);
|
||||
|
|
@ -665,62 +661,8 @@
|
|||
color: var(--tn-text-normal);
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
TASK CREATION MODAL SPECIFIC
|
||||
===================================================================== */
|
||||
|
||||
|
||||
.tasknotes-plugin .task-creation-modal__preview {
|
||||
padding: var(--tn-spacing-md) var(--tn-spacing-lg);
|
||||
background: var(--tn-bg-secondary);
|
||||
border: 1px solid var(--tn-border-color);
|
||||
border-radius: var(--tn-radius-sm);
|
||||
font-size: var(--tn-font-size-sm);
|
||||
font-family: var(--font-monospace);
|
||||
color: var(--tn-text-muted);
|
||||
margin-top: var(--tn-spacing-md);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tasknotes-plugin .task-creation-modal__preview--valid {
|
||||
background: rgba(var(--tn-interactive-success), 0.1);
|
||||
border-color: var(--tn-interactive-success);
|
||||
color: var(--tn-text-normal);
|
||||
}
|
||||
|
||||
.tasknotes-plugin .task-creation-modal__preview--error {
|
||||
background: rgba(var(--tn-color-error), 0.1);
|
||||
border-color: var(--tn-color-error);
|
||||
color: var(--tn-color-error);
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
TASK EDIT MODAL SPECIFIC
|
||||
===================================================================== */
|
||||
|
||||
.tasknotes-plugin .task-edit-modal__metadata {
|
||||
margin-top: var(--tn-spacing-xxl);
|
||||
padding-top: var(--tn-spacing-lg);
|
||||
border-top: 1px solid var(--tn-border-color);
|
||||
}
|
||||
|
||||
.tasknotes-plugin .task-edit-modal__metadata-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tn-spacing-md);
|
||||
}
|
||||
|
||||
.tasknotes-plugin .task-edit-modal__metadata-item {
|
||||
font-size: var(--tn-font-size-sm);
|
||||
color: var(--tn-text-muted);
|
||||
line-height: 1.4;
|
||||
padding: var(--tn-spacing-md) var(--tn-spacing-lg);
|
||||
background: var(--tn-bg-secondary);
|
||||
border: 1px solid var(--tn-border-color);
|
||||
border-radius: var(--tn-radius-sm);
|
||||
font-weight: var(--tn-font-weight-normal);
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
QUICK DATE MODALS SPECIFIC
|
||||
===================================================================== */
|
||||
|
|
@ -844,8 +786,6 @@
|
|||
===================================================================== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tasknotes-plugin .task-creation-modal,
|
||||
.tasknotes-plugin .task-edit-modal,
|
||||
.tasknotes-plugin .due-date-modal,
|
||||
.tasknotes-plugin .scheduled-date-modal {
|
||||
padding: var(--tn-spacing-lg);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* =====================================================================
|
||||
MINIMALIST TASK MODAL - Google Keep/Todoist Inspired
|
||||
TASK MODAL - Google Keep/Todoist Inspired
|
||||
===================================================================== */
|
||||
|
||||
.tasknotes-plugin .minimalist-task-modal {
|
||||
|
|
@ -20,7 +20,7 @@ This document provides a comprehensive analysis of the test suite implementation
|
|||
- ✅ **TaskCard.test.ts** - Task card rendering, interactions, status updates
|
||||
|
||||
#### Modals (`tests/unit/modals/`)
|
||||
- ✅ **BaseTaskModal.test.ts** - Base modal functionality, form utilities, RRule handling
|
||||
- ✅ **TaskModal.test.ts** - Base modal functionality, form utilities, RRule handling
|
||||
- ✅ **TaskCreationModal.test.ts** - Task creation, validation, natural language processing
|
||||
|
||||
### 2. Integration Tests (`tests/integration/`)
|
||||
|
|
|
|||
|
|
@ -1,757 +0,0 @@
|
|||
/**
|
||||
* BaseTaskModal Tests
|
||||
*
|
||||
* Tests for the BaseTaskModal base class including:
|
||||
* - Form creation and management utilities
|
||||
* - RRule parsing and generation
|
||||
* - Date/time input handling
|
||||
* - Autocomplete functionality
|
||||
* - Validation helpers
|
||||
* - Character counting utilities
|
||||
* - Form state management
|
||||
* - Error handling and edge cases
|
||||
*/
|
||||
|
||||
import { BaseTaskModal } from '../../../src/modals/BaseTaskModal';
|
||||
import { RRule, Frequency } from 'rrule';
|
||||
import { MockObsidian } from '../../__mocks__/obsidian';
|
||||
import type { App } from 'obsidian';
|
||||
|
||||
// Type helper to safely cast mock App to real App type
|
||||
const createMockApp = (mockApp: any): App => mockApp as unknown as App;
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('obsidian');
|
||||
jest.mock('rrule');
|
||||
|
||||
// Mock date utils
|
||||
jest.mock('../../../src/utils/dateUtils', () => ({
|
||||
normalizeDateString: jest.fn((date) => date?.split('T')[0] || date),
|
||||
validateDateInput: jest.fn(() => true),
|
||||
hasTimeComponent: jest.fn((date) => date?.includes('T')),
|
||||
getDatePart: jest.fn((date) => date?.split('T')[0] || date),
|
||||
getTimePart: jest.fn((date) => date?.includes('T') ? '10:00' : null),
|
||||
combineDateAndTime: jest.fn((date, time) => time ? `${date}T${time}` : date),
|
||||
validateDateTimeInput: jest.fn(() => true)
|
||||
}));
|
||||
|
||||
// Concrete implementation of BaseTaskModal for testing
|
||||
class TestTaskModal extends BaseTaskModal {
|
||||
protected async initializeFormData(): Promise<void> {
|
||||
this.title = 'Test Task';
|
||||
this.priority = 'normal';
|
||||
this.status = 'open';
|
||||
}
|
||||
|
||||
protected createActionButtons(container: HTMLElement): void {
|
||||
const button = container.createEl('button', { text: 'Test Button' });
|
||||
button.addEventListener('click', () => this.handleSubmit());
|
||||
}
|
||||
|
||||
protected async handleSubmit(): Promise<void> {
|
||||
// Test implementation
|
||||
}
|
||||
}
|
||||
|
||||
describe('BaseTaskModal', () => {
|
||||
let mockApp: App;
|
||||
let mockPlugin: any;
|
||||
let modal: TestTaskModal;
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
MockObsidian.reset();
|
||||
|
||||
// Mock RRule constructor and toString to work properly with instance data
|
||||
const mockRRule = jest.requireActual('../../../tests/__mocks__/rrule.ts');
|
||||
|
||||
// Store original static methods before overriding
|
||||
const originalRRule = RRule;
|
||||
const originalFromString = RRule.fromString;
|
||||
|
||||
// Mock the constructor to properly set instance data
|
||||
(RRule as any) = jest.fn().mockImplementation(function(this: any, options: any = {}) {
|
||||
this.options = options;
|
||||
return this;
|
||||
});
|
||||
|
||||
// Restore static methods
|
||||
(RRule as any).fromString = originalFromString;
|
||||
Object.assign(RRule, originalRRule);
|
||||
|
||||
// Set up the prototype with proper toString method
|
||||
(RRule as any).prototype.toString = function(this: any) {
|
||||
// Manually implement the toString logic
|
||||
if (!this.options) {
|
||||
return 'FREQ=DAILY;INTERVAL=1';
|
||||
}
|
||||
|
||||
const { freq, interval = 1, byweekday, bymonthday, bymonth, bysetpos, until, count } = this.options;
|
||||
let result = '';
|
||||
|
||||
const Frequency = mockRRule.Frequency;
|
||||
switch (freq) {
|
||||
case Frequency.DAILY:
|
||||
result = 'FREQ=DAILY';
|
||||
break;
|
||||
case Frequency.WEEKLY:
|
||||
result = 'FREQ=WEEKLY';
|
||||
break;
|
||||
case Frequency.MONTHLY:
|
||||
result = 'FREQ=MONTHLY';
|
||||
break;
|
||||
case Frequency.YEARLY:
|
||||
result = 'FREQ=YEARLY';
|
||||
break;
|
||||
default:
|
||||
return 'FREQ=DAILY;INTERVAL=1';
|
||||
}
|
||||
|
||||
if (interval && interval > 1) {
|
||||
result += `;INTERVAL=${interval}`;
|
||||
}
|
||||
|
||||
if (byweekday && byweekday.length > 0) {
|
||||
const dayMap = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
|
||||
const days = byweekday.map((w: any) => dayMap[w.weekday || w]).filter(Boolean);
|
||||
if (days.length > 0) {
|
||||
result += `;BYDAY=${days.join(',')}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (bymonthday && bymonthday.length > 0) {
|
||||
result += `;BYMONTHDAY=${bymonthday.join(',')}`;
|
||||
}
|
||||
|
||||
if (bymonth && bymonth.length > 0) {
|
||||
result += `;BYMONTH=${bymonth.join(',')}`;
|
||||
}
|
||||
|
||||
if (bysetpos && bysetpos.length > 0) {
|
||||
result += `;BYSETPOS=${bysetpos.join(',')}`;
|
||||
}
|
||||
|
||||
if (until) {
|
||||
const dateStr = until.toISOString().split('T')[0].replace(/-/g, '');
|
||||
result += `;UNTIL=${dateStr}`;
|
||||
}
|
||||
|
||||
if (count) {
|
||||
result += `;COUNT=${count}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Create DOM container
|
||||
document.body.innerHTML = '';
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Mock app
|
||||
mockApp = createMockApp(MockObsidian.createMockApp());
|
||||
|
||||
// Mock plugin
|
||||
mockPlugin = {
|
||||
app: mockApp,
|
||||
settings: {
|
||||
taskTag: 'task',
|
||||
customStatuses: [
|
||||
{ value: 'open', label: 'Open' },
|
||||
{ value: 'done', label: 'Done' }
|
||||
],
|
||||
customPriorities: [
|
||||
{ value: 'normal', label: 'Normal' },
|
||||
{ value: 'high', label: 'High' }
|
||||
]
|
||||
},
|
||||
cacheManager: {
|
||||
getAllContexts: jest.fn().mockReturnValue(['work', 'home', 'urgent']),
|
||||
getAllTags: jest.fn().mockReturnValue(['task', 'important', 'review'])
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-ignore: Mock App type is compatible at runtime despite TypeScript warnings
|
||||
modal = new TestTaskModal(createMockApp(mockApp), mockPlugin);
|
||||
|
||||
// Mock console methods
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
jest.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
if (modal) {
|
||||
modal.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe('Modal Initialization', () => {
|
||||
it('should initialize with default properties', () => {
|
||||
expect(modal.title).toBe('');
|
||||
expect(modal.dueDate).toBe('');
|
||||
expect(modal.scheduledDate).toBe('');
|
||||
expect(modal.priority).toBe('normal');
|
||||
expect(modal.status).toBe('open');
|
||||
expect(modal.contexts).toBe('');
|
||||
expect(modal.tags).toBe('');
|
||||
expect(modal.timeEstimate).toBe(0);
|
||||
expect((modal as any).frequencyMode).toBe('NONE');
|
||||
});
|
||||
|
||||
it('should initialize form data when called', async () => {
|
||||
await (modal as any).initializeFormData();
|
||||
|
||||
expect(modal.title).toBe('Test Task');
|
||||
expect(modal.priority).toBe('normal');
|
||||
expect(modal.status).toBe('open');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cache Management', () => {
|
||||
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('Day Name Conversion', () => {
|
||||
it('should convert abbreviations to full names', () => {
|
||||
const abbrs = ['mon', 'tue', 'fri'];
|
||||
const fullNames = (modal as any).convertAbbreviationsToFullNames(abbrs);
|
||||
|
||||
expect(fullNames).toEqual(['Monday', 'Tuesday', 'Friday']);
|
||||
});
|
||||
|
||||
it('should convert full names to abbreviations', () => {
|
||||
const fullNames = ['Monday', 'Tuesday', 'Friday'];
|
||||
const abbrs = (modal as any).convertFullNamesToAbbreviations(fullNames);
|
||||
|
||||
expect(abbrs).toEqual(['mon', 'tue', 'fri']);
|
||||
});
|
||||
|
||||
it('should filter out invalid day names', () => {
|
||||
const invalid = ['invalid', 'mon', 'not-a-day'];
|
||||
const fullNames = (modal as any).convertAbbreviationsToFullNames(invalid);
|
||||
|
||||
expect(fullNames).toEqual(['Monday']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RRule Parsing', () => {
|
||||
it('should parse daily recurrence rule', () => {
|
||||
const rruleString = 'FREQ=DAILY;INTERVAL=2';
|
||||
(modal as any).parseRRuleString(rruleString);
|
||||
|
||||
expect((modal as any).frequencyMode).toBe('DAILY');
|
||||
expect((modal as any).rruleInterval).toBe(2);
|
||||
});
|
||||
|
||||
it('should parse weekly recurrence rule', () => {
|
||||
const rruleString = 'FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR';
|
||||
(modal as any).parseRRuleString(rruleString);
|
||||
|
||||
expect((modal as any).frequencyMode).toBe('WEEKLY');
|
||||
expect((modal as any).rruleInterval).toBe(1);
|
||||
expect((modal as any).rruleByWeekday).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should parse monthly recurrence rule', () => {
|
||||
const rruleString = 'FREQ=MONTHLY;BYMONTHDAY=15';
|
||||
(modal as any).parseRRuleString(rruleString);
|
||||
|
||||
expect((modal as any).frequencyMode).toBe('MONTHLY');
|
||||
expect((modal as any).monthlyMode).toBe('day');
|
||||
expect((modal as any).rruleByMonthday).toEqual([15]);
|
||||
});
|
||||
|
||||
it('should parse yearly recurrence rule', () => {
|
||||
const rruleString = 'FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=15';
|
||||
(modal as any).parseRRuleString(rruleString);
|
||||
|
||||
expect((modal as any).frequencyMode).toBe('YEARLY');
|
||||
expect((modal as any).rruleByMonth).toEqual([6]);
|
||||
expect((modal as any).rruleByMonthday).toEqual([15]);
|
||||
});
|
||||
|
||||
it('should parse recurrence with end date', () => {
|
||||
const until = new Date('2025-12-31');
|
||||
const rruleString = `FREQ=DAILY;UNTIL=${until.toISOString().split('T')[0].replace(/-/g, '')}`;
|
||||
(modal as any).parseRRuleString(rruleString);
|
||||
|
||||
expect((modal as any).endMode).toBe('until');
|
||||
expect((modal as any).rruleUntil).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should parse recurrence with count', () => {
|
||||
const rruleString = 'FREQ=DAILY;COUNT=10';
|
||||
(modal as any).parseRRuleString(rruleString);
|
||||
|
||||
expect((modal as any).endMode).toBe('count');
|
||||
expect((modal as any).rruleCount).toBe(10);
|
||||
});
|
||||
|
||||
it('should handle empty rrule string', () => {
|
||||
(modal as any).parseRRuleString('');
|
||||
|
||||
expect((modal as any).frequencyMode).toBe('NONE');
|
||||
expect((modal as any).rruleInterval).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle invalid rrule string', () => {
|
||||
(modal as any).parseRRuleString('INVALID_RRULE');
|
||||
|
||||
expect((modal as any).frequencyMode).toBe('NONE');
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should determine monthly weekday mode', () => {
|
||||
const rruleString = 'FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1';
|
||||
(modal as any).parseRRuleString(rruleString);
|
||||
|
||||
expect((modal as any).frequencyMode).toBe('MONTHLY');
|
||||
expect((modal as any).monthlyMode).toBe('weekday');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RRule Generation', () => {
|
||||
it('should generate daily recurrence rule', () => {
|
||||
(modal as any).frequencyMode = 'DAILY';
|
||||
(modal as any).rruleInterval = 3;
|
||||
|
||||
const rruleString = (modal as any).generateRRuleString();
|
||||
|
||||
expect(rruleString).toContain('FREQ=DAILY');
|
||||
expect(rruleString).toContain('INTERVAL=3');
|
||||
});
|
||||
|
||||
it('should generate weekly recurrence rule', () => {
|
||||
(modal as any).frequencyMode = 'WEEKLY';
|
||||
(modal as any).rruleByWeekday = [{ weekday: 0 }, { weekday: 2 }]; // Monday, Wednesday
|
||||
|
||||
const rruleString = (modal as any).generateRRuleString();
|
||||
|
||||
expect(rruleString).toContain('FREQ=WEEKLY');
|
||||
});
|
||||
|
||||
it('should generate monthly day recurrence rule', () => {
|
||||
(modal as any).frequencyMode = 'MONTHLY';
|
||||
(modal as any).monthlyMode = 'day';
|
||||
(modal as any).rruleByMonthday = [15];
|
||||
|
||||
const rruleString = (modal as any).generateRRuleString();
|
||||
|
||||
expect(rruleString).toContain('FREQ=MONTHLY');
|
||||
});
|
||||
|
||||
it('should generate monthly weekday recurrence rule', () => {
|
||||
(modal as any).frequencyMode = 'MONTHLY';
|
||||
(modal as any).monthlyMode = 'weekday';
|
||||
(modal as any).rruleByWeekday = [{ weekday: 0 }];
|
||||
(modal as any).rruleBySetpos = [1];
|
||||
|
||||
const rruleString = (modal as any).generateRRuleString();
|
||||
|
||||
expect(rruleString).toContain('FREQ=MONTHLY');
|
||||
});
|
||||
|
||||
it('should generate yearly recurrence rule', () => {
|
||||
(modal as any).frequencyMode = 'YEARLY';
|
||||
(modal as any).rruleByMonth = [6];
|
||||
(modal as any).rruleByMonthday = [15];
|
||||
|
||||
const rruleString = (modal as any).generateRRuleString();
|
||||
|
||||
expect(rruleString).toContain('FREQ=YEARLY');
|
||||
});
|
||||
|
||||
it('should generate rule with until date', () => {
|
||||
(modal as any).frequencyMode = 'DAILY';
|
||||
(modal as any).endMode = 'until';
|
||||
(modal as any).rruleUntil = new Date('2025-12-31');
|
||||
|
||||
const rruleString = (modal as any).generateRRuleString();
|
||||
|
||||
expect(rruleString).toContain('FREQ=DAILY');
|
||||
});
|
||||
|
||||
it('should generate rule with count', () => {
|
||||
(modal as any).frequencyMode = 'DAILY';
|
||||
(modal as any).endMode = 'count';
|
||||
(modal as any).rruleCount = 10;
|
||||
|
||||
const rruleString = (modal as any).generateRRuleString();
|
||||
|
||||
expect(rruleString).toContain('FREQ=DAILY');
|
||||
});
|
||||
|
||||
it('should return empty string for NONE frequency', () => {
|
||||
(modal as any).frequencyMode = 'NONE';
|
||||
|
||||
const rruleString = (modal as any).generateRRuleString();
|
||||
|
||||
expect(rruleString).toBe('');
|
||||
});
|
||||
|
||||
it('should handle generation errors gracefully', () => {
|
||||
// Store the original RRule to restore it after the test
|
||||
const originalRRule = RRule;
|
||||
|
||||
// Override the working RRule mock to throw an error
|
||||
(RRule as any) = jest.fn().mockImplementation(() => {
|
||||
throw new Error('RRule creation failed');
|
||||
});
|
||||
|
||||
(modal as any).frequencyMode = 'DAILY';
|
||||
const rruleString = (modal as any).generateRRuleString();
|
||||
|
||||
expect(rruleString).toBe('');
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
|
||||
// Restore the original RRule for subsequent tests
|
||||
(global as any).RRule = originalRRule;
|
||||
Object.assign(RRule, originalRRule);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RRule Human Text', () => {
|
||||
it('should generate human text for valid rule', () => {
|
||||
modal.recurrenceRule = 'FREQ=DAILY;INTERVAL=1';
|
||||
|
||||
const humanText = (modal as any).getRRuleHumanText();
|
||||
|
||||
expect(humanText).toBe('every day'); // Mocked response
|
||||
});
|
||||
|
||||
it('should handle no recurrence rule', () => {
|
||||
modal.recurrenceRule = '';
|
||||
|
||||
const humanText = (modal as any).getRRuleHumanText();
|
||||
|
||||
expect(humanText).toBe('No recurrence');
|
||||
});
|
||||
|
||||
it('should handle invalid recurrence rule', () => {
|
||||
modal.recurrenceRule = 'INVALID_RULE';
|
||||
|
||||
const humanText = (modal as any).getRRuleHumanText();
|
||||
|
||||
expect(humanText).toBe('Invalid recurrence rule');
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Group Creation', () => {
|
||||
it('should create form group with label and input container', () => {
|
||||
const formGroup = (modal as any).createFormGroup(container, 'Test Label', (inputContainer: HTMLElement) => {
|
||||
inputContainer.createEl('input', { type: 'text' });
|
||||
});
|
||||
|
||||
expect(formGroup.classList.contains('modal-form__group')).toBe(true);
|
||||
|
||||
const label = formGroup.querySelector('.modal-form__label');
|
||||
expect(label?.textContent).toBe('Test Label');
|
||||
|
||||
const inputContainer = formGroup.querySelector('.modal-form__input-container');
|
||||
expect(inputContainer).toBeTruthy();
|
||||
|
||||
const input = inputContainer?.querySelector('input');
|
||||
expect(input).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should create accessible form group with proper ARIA labels', () => {
|
||||
const formGroup = (modal as any).createFormGroup(container, 'Accessible Label', (inputContainer: HTMLElement) => {
|
||||
inputContainer.createEl('input', { type: 'text' });
|
||||
});
|
||||
|
||||
const label = formGroup.querySelector('.modal-form__label');
|
||||
const inputContainer = formGroup.querySelector('.modal-form__input-container');
|
||||
|
||||
expect(label?.id).toBeTruthy();
|
||||
expect(inputContainer?.getAttribute('aria-labelledby')).toBe(label?.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Autocomplete Input', () => {
|
||||
beforeEach(() => {
|
||||
(modal as any).existingContexts = ['work', 'home', 'urgent'];
|
||||
(modal as any).existingTags = ['important', 'review'];
|
||||
});
|
||||
|
||||
it('should create autocomplete input with proper attributes', async () => {
|
||||
const getSuggestions = jest.fn().mockResolvedValue(['work', 'home']);
|
||||
const onChange = jest.fn();
|
||||
|
||||
await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange);
|
||||
|
||||
const input = container.querySelector('input');
|
||||
expect(input).toBeTruthy();
|
||||
expect(input?.getAttribute('aria-autocomplete')).toBe('list');
|
||||
expect(input?.getAttribute('role')).toBe('combobox');
|
||||
expect(input?.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('should update field value on input', async () => {
|
||||
const getSuggestions = jest.fn().mockResolvedValue([]);
|
||||
const onChange = jest.fn();
|
||||
|
||||
await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange);
|
||||
|
||||
const input = container.querySelector('input') as HTMLInputElement;
|
||||
input.value = 'work, home';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
|
||||
expect(modal.contexts).toBe('work, home');
|
||||
expect(onChange).toHaveBeenCalledWith('work, home');
|
||||
});
|
||||
|
||||
it('should show suggestions on focus', async () => {
|
||||
const getSuggestions = jest.fn().mockResolvedValue(['work', 'home', 'urgent']);
|
||||
const onChange = jest.fn();
|
||||
|
||||
await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange);
|
||||
|
||||
const input = container.querySelector('input') as HTMLInputElement;
|
||||
input.focus();
|
||||
|
||||
// Allow async operations to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(getSuggestions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should hide suggestions on blur', async () => {
|
||||
const getSuggestions = jest.fn().mockResolvedValue(['work', 'home']);
|
||||
const onChange = jest.fn();
|
||||
|
||||
await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange);
|
||||
|
||||
const input = container.querySelector('input') as HTMLInputElement;
|
||||
input.focus();
|
||||
input.blur();
|
||||
|
||||
// Allow timeout to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
|
||||
const suggestions = container.querySelector('.modal-form__suggestions');
|
||||
expect(suggestions).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should handle keyboard navigation in suggestions', async () => {
|
||||
const getSuggestions = jest.fn().mockResolvedValue(['work', 'home', 'urgent']);
|
||||
const onChange = jest.fn();
|
||||
|
||||
await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange);
|
||||
|
||||
const input = container.querySelector('input') as HTMLInputElement;
|
||||
|
||||
// Mock suggestions being shown
|
||||
const suggestionsList = container.createDiv({ cls: 'modal-form__suggestions' });
|
||||
suggestionsList.createDiv({ cls: 'modal-form__suggestion', text: 'work' });
|
||||
suggestionsList.createDiv({ cls: 'modal-form__suggestion', text: 'home' });
|
||||
|
||||
const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true });
|
||||
input.dispatchEvent(arrowDownEvent);
|
||||
|
||||
// Should not throw errors
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('should fetch fresh data when suggestions are empty', async () => {
|
||||
const getSuggestions = jest.fn().mockResolvedValue([]);
|
||||
const onChange = jest.fn();
|
||||
|
||||
await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange);
|
||||
|
||||
const input = container.querySelector('input') as HTMLInputElement;
|
||||
input.focus();
|
||||
|
||||
// Allow async operations to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(mockPlugin.cacheManager.getAllContexts).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Suggestion Management', () => {
|
||||
it('should show suggestions list', () => {
|
||||
const input = container.createEl('input');
|
||||
const suggestions = ['work', 'home', 'urgent'];
|
||||
const onChange = jest.fn();
|
||||
|
||||
(modal as any).showSuggestions(container, suggestions, input, onChange);
|
||||
|
||||
const suggestionsList = container.querySelector('.modal-form__suggestions');
|
||||
expect(suggestionsList).toBeTruthy();
|
||||
expect(suggestionsList?.getAttribute('role')).toBe('listbox');
|
||||
expect(input.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('should hide suggestions when list is empty', () => {
|
||||
const input = container.createEl('input');
|
||||
const suggestions: string[] = [];
|
||||
const onChange = jest.fn();
|
||||
|
||||
(modal as any).showSuggestions(container, suggestions, input, onChange);
|
||||
|
||||
const suggestionsList = container.querySelector('.modal-form__suggestions');
|
||||
expect(suggestionsList).toBeFalsy();
|
||||
expect(input.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('should hide existing suggestions before showing new ones', () => {
|
||||
const input = container.createEl('input');
|
||||
|
||||
// Create existing suggestions
|
||||
container.createDiv({ cls: 'modal-form__suggestions' });
|
||||
|
||||
const suggestions = ['work', 'home'];
|
||||
const onChange = jest.fn();
|
||||
|
||||
(modal as any).showSuggestions(container, suggestions, input, onChange);
|
||||
|
||||
const suggestionsLists = container.querySelectorAll('.modal-form__suggestions');
|
||||
expect(suggestionsLists.length).toBe(1); // Should only have one list
|
||||
});
|
||||
|
||||
it('should filter suggestions based on current input', () => {
|
||||
const input = container.createEl('input') as HTMLInputElement;
|
||||
input.value = 'work, ho'; // Partial input
|
||||
|
||||
const suggestions = ['work', 'home', 'urgent'];
|
||||
const onChange = jest.fn();
|
||||
|
||||
(modal as any).showSuggestions(container, suggestions, input, onChange);
|
||||
|
||||
// Should show suggestions (exact filtering logic is in full implementation)
|
||||
const suggestionsList = container.querySelector('.modal-form__suggestions');
|
||||
expect(suggestionsList).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle suggestion selection', () => {
|
||||
const input = container.createEl('input') as HTMLInputElement;
|
||||
input.value = 'work, ';
|
||||
|
||||
const onChange = jest.fn();
|
||||
|
||||
(modal as any).applySuggestion(input, 'home', onChange);
|
||||
|
||||
expect(input.value).toBe('work, home');
|
||||
expect(onChange).toHaveBeenCalledWith('work, home');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle errors in RRule parsing gracefully', () => {
|
||||
// Mock RRule.fromString to throw error for this test
|
||||
const originalFromString = RRule.fromString;
|
||||
RRule.fromString = jest.fn().mockImplementationOnce(() => {
|
||||
throw new Error('Invalid RRule');
|
||||
});
|
||||
|
||||
(modal as any).parseRRuleString('INVALID_RULE');
|
||||
|
||||
expect((modal as any).frequencyMode).toBe('NONE');
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
|
||||
// Restore original fromString
|
||||
RRule.fromString = originalFromString;
|
||||
});
|
||||
|
||||
it('should handle errors in suggestion fetching', async () => {
|
||||
mockPlugin.cacheManager.getAllContexts.mockImplementation(() => {
|
||||
throw new Error('Cache error');
|
||||
});
|
||||
|
||||
const contexts = await modal.getExistingContexts();
|
||||
|
||||
// Should not throw, might return empty array or cached data
|
||||
expect(Array.isArray(contexts)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle malformed autocomplete input', async () => {
|
||||
const getSuggestions = jest.fn().mockRejectedValue(new Error('Fetch error'));
|
||||
const onChange = jest.fn();
|
||||
|
||||
await expect(
|
||||
(modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Management', () => {
|
||||
it('should reset RRule properties correctly', () => {
|
||||
// Set some properties first
|
||||
(modal as any).frequencyMode = 'WEEKLY';
|
||||
(modal as any).rruleInterval = 2;
|
||||
(modal as any).rruleByWeekday = [{ weekday: 0 }];
|
||||
(modal as any).endMode = 'count';
|
||||
(modal as any).rruleCount = 10;
|
||||
|
||||
(modal as any).resetRRuleProperties();
|
||||
|
||||
expect((modal as any).frequencyMode).toBe('NONE');
|
||||
expect((modal as any).rruleInterval).toBe(1);
|
||||
expect((modal as any).rruleByWeekday).toEqual([]);
|
||||
expect((modal as any).endMode).toBe('never');
|
||||
expect((modal as any).rruleCount).toBeNull();
|
||||
});
|
||||
|
||||
it('should maintain form field state correctly', () => {
|
||||
modal.title = 'Updated Task';
|
||||
modal.priority = 'high';
|
||||
modal.contexts = 'work, urgent';
|
||||
|
||||
expect(modal.title).toBe('Updated Task');
|
||||
expect(modal.priority).toBe('high');
|
||||
expect(modal.contexts).toBe('work, urgent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance and Memory', () => {
|
||||
it('should handle multiple rapid suggestion requests', async () => {
|
||||
const getSuggestions = jest.fn().mockResolvedValue(['work', 'home', 'urgent']);
|
||||
const onChange = jest.fn();
|
||||
|
||||
await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange);
|
||||
|
||||
const input = container.querySelector('input') as HTMLInputElement;
|
||||
|
||||
// Rapid focus/blur cycles
|
||||
for (let i = 0; i < 10; i++) {
|
||||
input.focus();
|
||||
input.blur();
|
||||
}
|
||||
|
||||
// Should not cause memory leaks or errors
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle large suggestion lists efficiently', () => {
|
||||
const largeSuggestions = Array.from({ length: 1000 }, (_, i) => `item-${i}`);
|
||||
const input = container.createEl('input');
|
||||
const onChange = jest.fn();
|
||||
|
||||
const startTime = Date.now();
|
||||
(modal as any).showSuggestions(container, largeSuggestions, input, onChange);
|
||||
const endTime = Date.now();
|
||||
|
||||
expect(endTime - startTime).toBeLessThan(100); // Should be fast
|
||||
|
||||
const suggestionsList = container.querySelector('.modal-form__suggestions');
|
||||
expect(suggestionsList).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -5,7 +5,8 @@
|
|||
* Jest interference issues and provide robust, reliable tests.
|
||||
*/
|
||||
|
||||
import { TaskCreationModal, TaskConversionOptions } from '../../../src/modals/TaskCreationModal';
|
||||
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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue