Compare commits

...

16 commits
0.1.1 ... main

Author SHA1 Message Date
Ryan Bantz
b425a7b952 test: add initial test suite for plugin functionality
- Add Jest configuration and TypeScript support
- Create tests for main plugin functionality (initialization, settings)
- Add tests for Asana API integration (workspaces, projects, tasks)
- Set up mock files for Obsidian API and test environment
- Add test scripts to package.json

This commit sets up a basic test infrastructure to ensure the plugin's
core functionality works as expected and to catch potential regressions
in future changes.
2025-04-29 17:03:43 -05:00
Ryan Bantz
e80e621e56 chore(release): bump version to 0.1.8 2025-04-05 21:42:56 -05:00
Ryan Bantz
8ff3a1a9d7 feat(settings): add option to pin My Tasks in project selector
- Add pinMyTasks setting with default value of true
- Add toggle in settings UI to control My Tasks pinning
- Update project list creation to respect pinMyTasks setting
- Maintain consistent ordering with existing pinned projects
2025-04-05 21:38:45 -05:00
Ryan Bantz
2bbd595640 feat(task-creation): add support for My Tasks project
- Add My Tasks as a project option in the project selector
- Support section selection for My Tasks
- Add task list GID fetching for My Tasks sections
- Handle task creation in My Tasks with proper assignment
2025-04-05 21:31:38 -05:00
Ryan Bantz
78dd73fae3 chore(release): bump version to 0.1.7
- Update manifest.json version number
- Prepare for new release with archived projects toggle and feedback section
2025-03-26 13:48:40 -05:00
Ryan Bantz
128ac55d20 docs(settings): add feedback section to settings page
- Add feedback section with link to user feedback form
- Add emoji indicators for better visibility
2025-03-26 13:41:42 -05:00
Ryan Bantz
8144510153 feat(settings): add toggle for showing archived projects
- Add showArchivedProjects setting to control visibility of archived projects
- Update fetchAsanaProjects to respect the new setting
- Default to hiding archived projects for backward compatibility
2025-03-26 13:18:48 -05:00
Ryan Bantz
a8d0e4945d Updated README.md 2025-03-05 14:01:25 -07:00
mryanb
b4e03e57af
Rename Readme.md to README.md
The file name needs to be CAPS for Obsidian to detect it.
2025-03-05 14:50:50 -06:00
Ryan Bantz
5e4b30ca91 release 2025-02-28 22:14:17 -06:00
Ryan Bantz
7de5392905 Updates to match style guide 2025-02-28 22:12:44 -06:00
mryanb
b0d36bff77
Update Readme.md
- Added a video showing and example item being moved to Asana.
2025-02-11 11:53:29 -06:00
Ryan Bantz
5a35b56144 Updated the command name to not include the plugin name 2025-02-10 09:08:11 -06:00
Ryan Bantz
1ec59d04c2 Remove obsidian from the id 2025-02-09 15:21:15 -06:00
Ryan Bantz
41287abd76 Update the package name 2025-02-09 15:14:41 -06:00
Ryan Bantz
6108b1ab9c Sync release versions 2025-02-09 15:09:18 -06:00
13 changed files with 4438 additions and 58 deletions

View file

@ -1,12 +1,19 @@
# Asana for Obsidian
Asana for Obsidan is a plugin that creates tasks with in Asana from the current line or selected text within Obsidian.
![2025-02-09_12-50-42 (1)](https://github.com/user-attachments/assets/469faaa3-a9fb-4437-9a9f-8b58c52f5dc3)
## Installation
This plugin isn't published yet so there are 2 ways you can install this plugin and test.
This plugin is available as an official Obsidian plugin.
- Option 1. Download the latest release from the [releases page] and unzip it in your plugins folder.
- Option 1. *(Recommended)* You can activate this plugin within Obsidian by doing the following:
1. "Settings > Community plugins" Click Browse community plugins
2. Search for "Asana"
3. Click Install
4. Once installed, close the community plugins window and activate the newly installed plugin
- Option 2. Use the [BRAT plugin](https://tfthacker.com/BRAT) for beta reviewing Obsidian plugins.
- Option 2. Download the latest release from the [releases page] and unzip it in your plugins folder.
- Option 3. Use the [BRAT plugin](https://tfthacker.com/BRAT) for beta reviewing Obsidian plugins.
- Install the BRAT plugin: [obsidian://show-plugin?id=obsidian42-brat](obsidian://show-plugin?id=obsidian42-brat)
- Open the command palette and run the command `BRAT: Add a beta plugin for testing`
- Paste the URL of this repository.
@ -14,7 +21,7 @@ This plugin isn't published yet so there are 2 ways you can install this plugin
## Getting Started / Configuration
1. Create a [Asana access token](https://app.asana.com/0/my-apps).
- This plugin reuqires an access token to integrate with Asana.
- This plugin requires an access token to integrate with Asana.
- Select `+ Create new token`
2. Add your access token to the plugin settings, click save key.

21
jest.config.js Normal file
View file

@ -0,0 +1,21 @@
module.exports = {
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: 'tsconfig.json'
}]
},
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
moduleNameMapper: {
'^obsidian$': '<rootDir>/tests/__mocks__/obsidian.js'
},
setupFiles: ['<rootDir>/tests/setup.js'],
transformIgnorePatterns: [
'node_modules/(?!(obsidian)/)'
],
testPathIgnorePatterns: [
'/node_modules/',
'/dist/'
]
};

View file

@ -1,7 +1,7 @@
{
"id": "asana-for-obsidian",
"id": "asana",
"name": "Asana",
"version": "0.1.0",
"version": "0.1.8",
"description": "Create Asana tasks from highlighted text or the current line in Obsidian.",
"minAppVersion": "1.5.0",
"author": "Ryan Bantz",

3827
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,19 +1,23 @@
{
"name": "asana-task-creator",
"version": "1.0.0",
"name": "obsidian-asana",
"version": "0.1.8",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
"version": "node version-bump.mjs && git add manifest.json versions.json",
"test": "jest --config jest.config.js",
"test:watch": "jest --watch --config jest.config.js"
},
"keywords": [],
"author": "",
"author": "Ryan Bantz",
"license": "MIT",
"devDependencies": {
"builtin-modules": "^4.0.0",
"esbuild": "0.17.3",
"jest": "^29.7.0",
"obsidian": "^1.7.2",
"ts-jest": "^29.1.1",
"tslib": "^2.8.1",
"typescript": "^4.0.0"
}

View file

@ -46,7 +46,7 @@ export async function fetchAsanaProjects(
try {
const response = await requestUrl({
url: `${ASANA_API_BASE_URL}/workspaces/${workspaceGid}/projects?is_archived=false`,
url: `${ASANA_API_BASE_URL}/workspaces/${workspaceGid}/projects${settings.showArchivedProjects ? '' : '?is_archived=false'}`,
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
@ -65,20 +65,20 @@ export async function fetchAsanaProjects(
}
/**
* Fetches a list of sections for a given project in Asana.
* @param projectGid - The Asana project ID.
* Fetches a list of sections for a given project or task list in Asana.
* @param gid - The Asana project ID or task list ID.
* @param settings - The plugin settings containing the Asana API token.
* @returns A list of sections.
*/
export async function fetchAsanaSections(
projectGid: string,
gid: string,
settings: AsanaPluginSettings
) {
const token = settings.asanaToken;
try {
const response = await requestUrl({
url: `${ASANA_API_BASE_URL}/projects/${projectGid}/sections`,
url: `${ASANA_API_BASE_URL}/projects/${gid}/sections`,
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
@ -126,8 +126,9 @@ export async function createTaskInAsana(
body: JSON.stringify({
data: {
name: taskName,
projects: [projectGid],
workspace: workspaceGid,
...(projectGid ? { projects: [projectGid] } : {}), // Only include projects if projectGid is provided
assignee: projectGid ? undefined : 'me', // Assign to me if it's a My Tasks task
},
}),
});
@ -135,7 +136,7 @@ export async function createTaskInAsana(
if (response.status >= 200 && response.status < 300) {
const taskGid = response.json.data.gid;
// Move task to the selected section (if provided)
// Move task to the selected section if provided
if (sectionGid) {
await requestUrl({
url: `${ASANA_API_BASE_URL}/sections/${sectionGid}/addTask`,
@ -166,7 +167,126 @@ export async function createTaskInAsana(
throw new Error(`Asana API Error: ${response.text}`);
}
} catch (error) {
console.error('Failed to create Asana task:', error);
console.error('Failed to create task:', error);
throw new Error('Failed to create task in Asana');
}
}
/**
* Fetches the current user's data from Asana.
* @param settings - The plugin settings containing the Asana API token.
* @returns The user data including gid.
*/
export async function fetchAsanaUser(settings: AsanaPluginSettings) {
const token = settings.asanaToken;
try {
const response = await requestUrl({
url: `${ASANA_API_BASE_URL}/users/me`,
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.status >= 200 && response.status < 300) {
console.log(response.json);
return response.json.data;
} else {
throw new Error(`Asana API Error: ${response.text}`);
}
} catch (error) {
console.error('Failed to fetch Asana user data:', error);
throw new Error('Failed to retrieve user data from Asana');
}
}
/**
* Fetches a list of sections from a user's My Tasks in a specific workspace. https://developers.asana.com/reference/getusertasklist
* @param workspaceGid - The Asana workspace ID.
* @param userGid - The user's GID.
* @param settings - The plugin settings containing the Asana API token.
* @returns A list of sections.
*/
export async function fetchMyTasksSections(
workspaceGid: string,
userGid: string,
settings: AsanaPluginSettings
) {
const token = settings.asanaToken;
console.log(`Making API call to: ${ASANA_API_BASE_URL}/users/me/user_task_list?workspace=${workspaceGid}`);
try {
// First, get the user's task list for the workspace
const taskListResponse = await requestUrl({
url: `${ASANA_API_BASE_URL}/users/me/user_task_list?workspace=${workspaceGid}`,
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
});
console.log('taskListResponse');
console.log(taskListResponse);
if (taskListResponse.status >= 200 && taskListResponse.status < 300) {
const taskListGid = taskListResponse.json.data.gid;
console.log('taskListGid');
console.log(taskListGid);
// Fetch sections using the same endpoint as projects, but with the task list GID
const sectionsResponse = await requestUrl({
url: `${ASANA_API_BASE_URL}/projects/${taskListGid}/sections`,
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (sectionsResponse.status >= 200 && sectionsResponse.status < 300) {
return sectionsResponse.json.data;
} else {
throw new Error(`Failed to fetch sections: ${sectionsResponse.text}`);
}
} else {
throw new Error(`Failed to fetch task list: ${taskListResponse.text}`);
}
} catch (error) {
console.error('Failed to fetch My Tasks sections:', error);
throw new Error('Failed to retrieve sections from My Tasks');
}
}
/**
* Fetches the task list GID for a user in a specific workspace.
* @param workspaceGid - The Asana workspace ID.
* @param settings - The plugin settings containing the Asana API token.
* @returns The task list GID.
*/
export async function fetchUserTaskListGid(
workspaceGid: string,
settings: AsanaPluginSettings
) {
const token = settings.asanaToken;
try {
const response = await requestUrl({
url: `${ASANA_API_BASE_URL}/users/me/user_task_list?workspace=${workspaceGid}`,
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.status >= 200 && response.status < 300) {
return response.json.data.gid;
} else {
throw new Error(`Failed to fetch task list: ${response.text}`);
}
} catch (error) {
console.error('Failed to fetch user task list:', error);
throw new Error('Failed to retrieve user task list from Asana');
}
}

View file

@ -11,9 +11,22 @@ import {
ToggleComponent
} from 'obsidian';
import { AsanaPluginSettings, DEFAULT_SETTINGS, AsanaSettingTab } from './settings/settings';
import { fetchAsanaWorkspaces, fetchAsanaProjects, fetchAsanaSections, createTaskInAsana } from './api/asanaApi';
import {
fetchAsanaWorkspaces,
fetchAsanaProjects,
fetchAsanaSections,
createTaskInAsana,
fetchAsanaUser,
fetchUserTaskListGid
} from './api/asanaApi';
import { FuzzySelectModal } from './ui/FuzzySelectModal';
interface Project {
name: string;
gid: string;
isMyTasks?: boolean;
}
/**
* Main plugin class.
*/
@ -27,7 +40,7 @@ export default class AsanaPlugin extends Plugin {
// Add command to the command palette
this.addCommand({
id: 'create-asana-task',
name: 'Create Asana Task',
name: 'Create task',
editorCallback: (editor: Editor) => this.createAsanaTask(editor),
});
@ -38,7 +51,7 @@ export default class AsanaPlugin extends Plugin {
this.registerEvent(
this.app.workspace.on('editor-menu', (menu, editor) => {
menu.addItem((item) => {
item.setTitle('Create Asana Task')
item.setTitle('Create Asana task')
.setIcon('checkmark')
.onClick(() => this.createAsanaTask(editor));
});
@ -163,12 +176,15 @@ export default class AsanaPlugin extends Plugin {
}
try {
// Fetch workspaces using the refactored function
const workspaces = await fetchAsanaWorkspaces(this.settings);
// Fetch workspaces and user data in parallel
const [workspaces, userData] = await Promise.all([
fetchAsanaWorkspaces(this.settings),
fetchAsanaUser(this.settings)
]);
// Prompt for workspace selection
const workspace = await this.promptForSelection(
'Select Workspace',
'Select workspace',
workspaces.map((ws: any) => ({ name: ws.name, gid: ws.gid }))
);
@ -183,6 +199,14 @@ export default class AsanaPlugin extends Plugin {
// Ensure pinned projects are sorted to the top
const pinnedProjectIds = new Set(this.settings.pinnedProjects);
// Add "My Tasks" as a special project option
const myTasksProject = {
name: "My Tasks",
gid: userData.gid,
isPinned: this.settings.pinMyTasks,
isMyTasks: true
};
// Separate pinned and non-pinned projects
const pinnedProjects = projects
.filter((p: any) => pinnedProjectIds.has(p.gid) || pinnedProjectIds.has(p.name))
@ -192,27 +216,36 @@ export default class AsanaPlugin extends Plugin {
.filter((p: any) => !pinnedProjectIds.has(p.gid) && !pinnedProjectIds.has(p.name))
.map((p: any) => ({ name: p.name, gid: p.gid, isPinned: false }));
// Combine pinned projects first, followed by the rest
const projectOptions = [...pinnedProjects, ...otherProjects];
// Combine My Tasks (if pinned), pinned projects, and other projects
const projectOptions = this.settings.pinMyTasks
? [myTasksProject, ...pinnedProjects, ...otherProjects]
: [...pinnedProjects, myTasksProject, ...otherProjects];
// Prompt for project selection
const project = await this.promptForSelection('Select Project', projectOptions);
const project = await this.promptForSelection('Select project', projectOptions);
if (!project) {
new Notice('Project selection was canceled.');
return null;
}
// Fetch sections using the refactored function
const sections = await fetchAsanaSections(project.gid, this.settings);
// Check the number of sections
let sections;
let selectedSection: { name: string; gid: string } | null = null;
// Fetch sections based on whether this is My Tasks or a regular project
const projectWithType = project as Project;
if (projectWithType.isMyTasks) {
const taskListGid = await fetchUserTaskListGid(workspace.gid, this.settings);
sections = await fetchAsanaSections(taskListGid, this.settings);
} else {
sections = await fetchAsanaSections(projectWithType.gid, this.settings);
}
// Check the number of sections
if (sections.length > 1) {
// Prompt user to select a section
selectedSection = await this.promptForSelection(
'Select Section',
'Select section',
sections.map((section: any) => ({ name: section.name, gid: section.gid }))
);
@ -225,14 +258,14 @@ export default class AsanaPlugin extends Plugin {
selectedSection = { name: sections[0].name, gid: sections[0].gid };
} else {
// No sections available, skip the section selection
new Notice('No sections found in this project. Skipping section selection.');
new Notice('No sections found. Skipping section selection.');
}
return {
workspaceGid: workspace.gid,
projectGid: project.gid,
projectGid: projectWithType.isMyTasks ? '' : projectWithType.gid,
sectionGid: selectedSection ? selectedSection.gid : '',
projectName: project.name,
projectName: projectWithType.name,
sectionName: selectedSection ? selectedSection.name : '',
};
} catch (error) {

View file

@ -9,6 +9,8 @@ export interface AsanaPluginSettings {
markTaskAsCompleted: boolean;
pinnedProjects: string[];
enableMarkdownLink: boolean;
showArchivedProjects: boolean;
pinMyTasks: boolean;
}
/**
@ -19,6 +21,8 @@ export const DEFAULT_SETTINGS: AsanaPluginSettings = {
markTaskAsCompleted: false,
pinnedProjects: [],
enableMarkdownLink: true,
showArchivedProjects: false,
pinMyTasks: true,
};
/**
@ -36,10 +40,8 @@ export class AsanaSettingTab extends PluginSettingTab {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'General Settings' });
new Setting(containerEl)
.setName('Status Check')
.setName('Status check')
.setDesc('check whether API key is saved. It does not guarantee that the API key is valid or invalid.')
.addButton(button => {
button.setButtonText('API Check').onClick(async () => {
@ -53,7 +55,7 @@ export class AsanaSettingTab extends PluginSettingTab {
// Personal Access Token Setting (Secure Input)
const patDesc = document.createDocumentFragment();
patDesc.createDiv({ text: 'Enter your Asana Personal Access Token.' });
patDesc.createDiv({ text: 'Enter your Asana personal access token.' });
patDesc.createDiv({
text: 'For security reasons, the saved API key is not shown in the input field after saving.',
});
@ -64,7 +66,7 @@ export class AsanaSettingTab extends PluginSettingTab {
let tempKeyValue = '';
new Setting(containerEl)
.setName('Asana Personal Access Token')
.setName('Asana personal access token')
.setDesc(patDesc)
.addText((text: TextComponent) => {
text.inputEl.type = 'password'; // Hide input value
@ -73,17 +75,17 @@ export class AsanaSettingTab extends PluginSettingTab {
});
})
.addButton((button) => {
button.setButtonText('Save Key').onClick(async () => {
button.setButtonText('Save key').onClick(async () => {
this.plugin.settings.asanaToken = tempKeyValue.trim();
await this.plugin.saveSettings();
new Notice('Asana API Key Saved');
new Notice('Asana API key saved');
tempKeyValue = ''; // Clear stored value
});
});
// Toggle: Mark Task as Completed
new Setting(containerEl)
.setName('Mark Task as Completed')
.setName('Mark task as completed')
.setDesc('Automatically mark the task as completed in Obsidian after creating it in Asana.')
.addToggle((toggle: ToggleComponent) => {
toggle.setValue(this.plugin.settings.markTaskAsCompleted)
@ -95,7 +97,7 @@ export class AsanaSettingTab extends PluginSettingTab {
// Toggle: Enable Markdown Link
new Setting(containerEl)
.setName('Enable Markdown Link')
.setName('Enable markdown link')
.setDesc('Insert a markdown link to the task in the note after task creation.')
.addToggle((toggle: ToggleComponent) => {
toggle.setValue(this.plugin.settings.enableMarkdownLink)
@ -105,9 +107,33 @@ export class AsanaSettingTab extends PluginSettingTab {
});
});
// Toggle: Pin My Tasks
new Setting(containerEl)
.setName('Pin My Tasks')
.setDesc('Always show My Tasks at the top of the project selector.')
.addToggle((toggle: ToggleComponent) => {
toggle.setValue(this.plugin.settings.pinMyTasks)
.onChange(async (value: boolean) => {
this.plugin.settings.pinMyTasks = value;
await this.plugin.saveSettings();
});
});
// Toggle: Show Archived Projects
new Setting(containerEl)
.setName('Show archived projects')
.setDesc('Include archived projects in the project selection modal.')
.addToggle((toggle: ToggleComponent) => {
toggle.setValue(this.plugin.settings.showArchivedProjects)
.onChange(async (value: boolean) => {
this.plugin.settings.showArchivedProjects = value;
await this.plugin.saveSettings();
});
});
// Pinned Projects Input (Scalable TextArea)
new Setting(containerEl)
.setName('Pinned Projects')
.setName('Pinned projects')
.setDesc('Enter project names or IDs to pin them in the project selection modal.')
.addTextArea((textArea) => {
textArea.inputEl.setAttribute('style', 'min-height: 100px; max-height: 300px; width: 100%; overflow-y: auto; resize: vertical;');
@ -118,5 +144,14 @@ export class AsanaSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
});
});
// Feedback Section
const feedbackDesc = document.createDocumentFragment();
const feedbackDiv = feedbackDesc.createDiv();
feedbackDiv.innerHTML = 'Got a bug, feature request, or success story? <a href="http://ryanbantz.com/feedback/asana-for-obsidian">Share your feedback</a>. 📢📢📢';
new Setting(containerEl)
.setName('Feedback')
.setDesc(feedbackDesc);
}
}

View file

@ -3,18 +3,18 @@ import { App, FuzzySuggestModal } from 'obsidian';
/**
* A modal for selecting items with fuzzy search support.
*/
export class FuzzySelectModal extends FuzzySuggestModal<{ name: string; gid: string; isPinned?: boolean }> {
private resolve: (value: { name: string; gid: string } | null) => void;
private items: Array<{ name: string; gid: string; isPinned?: boolean }>;
export class FuzzySelectModal extends FuzzySuggestModal<{ name: string; gid: string; isPinned?: boolean; isMyTasks?: boolean }> {
private resolve: (value: { name: string; gid: string; isMyTasks?: boolean } | null) => void;
private items: Array<{ name: string; gid: string; isPinned?: boolean; isMyTasks?: boolean }>;
private title: string;
private selectedItem: { name: string; gid: string } | null = null;
private selectedItem: { name: string; gid: string; isMyTasks?: boolean } | null = null;
private resolved: boolean = false; // Prevents resolving multiple times
constructor(
app: App,
title: string,
items: Array<{ name: string; gid: string; isPinned?: boolean }>,
resolve: (value: { name: string; gid: string } | null) => void
items: Array<{ name: string; gid: string; isPinned?: boolean; isMyTasks?: boolean }>,
resolve: (value: { name: string; gid: string; isMyTasks?: boolean } | null) => void
) {
super(app);
this.title = title;
@ -28,15 +28,18 @@ export class FuzzySelectModal extends FuzzySuggestModal<{ name: string; gid: str
this.setPlaceholder(this.title);
}
getItems(): Array<{ name: string; gid: string; isPinned?: boolean }> {
getItems(): Array<{ name: string; gid: string; isPinned?: boolean; isMyTasks?: boolean }> {
return this.items;
}
getItemText(item: { name: string; gid: string; isPinned?: boolean }): string {
getItemText(item: { name: string; gid: string; isPinned?: boolean; isMyTasks?: boolean }): string {
if (item.isMyTasks) {
return '👤 My Tasks';
}
return item.isPinned ? `📌 ${item.name.replace(/^📌 /, '')}` : item.name;
}
onChooseItem(item: { name: string; gid: string }, evt: MouseEvent | KeyboardEvent) {
onChooseItem(item: { name: string; gid: string; isMyTasks?: boolean }, evt: MouseEvent | KeyboardEvent) {
if (!this.resolved) {
// console.log(`ITEM CHOSEN - ${item.name} (gid: ${item.gid})`);
this.selectedItem = item;

115
tests/__mocks__/obsidian.js Normal file
View file

@ -0,0 +1,115 @@
class App {
constructor() {
this.workspace = {
activeLeaf: {
view: {
editor: {
getCursor: () => ({ line: 0, ch: 0 }),
getLine: () => '',
replaceRange: jest.fn()
}
}
}
};
}
}
class Editor {
constructor() {
this.getCursor = jest.fn();
this.getLine = jest.fn();
this.replaceRange = jest.fn();
}
}
class MarkdownView {
constructor() {
this.editor = new Editor();
}
}
class Notice {
constructor(message) {
this.message = message;
}
}
class Plugin {
constructor(app, manifest) {
this.app = app;
this.manifest = manifest;
}
loadData() {
return Promise.resolve({});
}
saveData(data) {
return Promise.resolve();
}
}
class PluginSettingTab {
constructor(app, plugin) {
this.app = app;
this.plugin = plugin;
}
}
class Setting {
constructor(containerEl) {
this.containerEl = containerEl;
}
setName(name) {
this.name = name;
return this;
}
setDesc(desc) {
this.desc = desc;
return this;
}
addText(callback) {
callback({ setValue: jest.fn(), onChange: jest.fn() });
return this;
}
addToggle(callback) {
callback({ setValue: jest.fn(), onChange: jest.fn() });
return this;
}
addButton(callback) {
callback({ setButtonText: jest.fn(), onClick: jest.fn() });
return this;
}
}
class FuzzySuggestModal {
constructor(app, items, resolve) {
this.app = app;
this.items = items;
this.resolve = resolve;
}
onOpen() {}
getItems() { return this.items; }
getItemText(item) { return item.name; }
onChooseItem(item) { this.resolve(item); }
}
const requestUrl = jest.fn();
module.exports = {
App,
Editor,
MarkdownView,
Notice,
Plugin,
PluginSettingTab,
Setting,
FuzzySuggestModal,
requestUrl
};

155
tests/api.test.js Normal file
View file

@ -0,0 +1,155 @@
const { requestUrl } = require('obsidian');
const {
fetchAsanaWorkspaces,
fetchAsanaProjects,
fetchAsanaSections,
createTaskInAsana
} = require('../src/api/asanaApi');
jest.mock('obsidian', () => ({
requestUrl: jest.fn()
}));
describe('Asana API', () => {
const mockSettings = {
asanaToken: 'test-token',
showArchivedProjects: false
};
beforeEach(() => {
jest.clearAllMocks();
});
test('fetchAsanaWorkspaces handles successful response', async () => {
const mockWorkspaces = [{ id: '1', name: 'Workspace 1' }];
requestUrl.mockResolvedValueOnce({
status: 200,
json: { data: mockWorkspaces }
});
const result = await fetchAsanaWorkspaces(mockSettings);
expect(result).toEqual(mockWorkspaces);
expect(requestUrl).toHaveBeenCalledWith({
url: 'https://app.asana.com/api/1.0/workspaces',
method: 'GET',
headers: {
Authorization: 'Bearer test-token'
}
});
});
test('fetchAsanaWorkspaces handles error response', async () => {
requestUrl.mockRejectedValueOnce(new Error('API Error'));
await expect(fetchAsanaWorkspaces(mockSettings)).rejects.toThrow('Failed to retrieve workspaces from Asana');
});
test('fetchAsanaProjects handles successful response', async () => {
const mockProjects = [{ id: '1', name: 'Project 1' }];
requestUrl.mockResolvedValueOnce({
status: 200,
json: { data: mockProjects }
});
const result = await fetchAsanaProjects('workspace-1', mockSettings);
expect(result).toEqual(mockProjects);
expect(requestUrl).toHaveBeenCalledWith({
url: 'https://app.asana.com/api/1.0/workspaces/workspace-1/projects?is_archived=false',
method: 'GET',
headers: {
Authorization: 'Bearer test-token'
}
});
});
test('createTaskInAsana handles successful response', async () => {
// Mock initial task creation
const mockTaskGid = '1234';
requestUrl.mockResolvedValueOnce({
status: 201,
json: { data: { gid: mockTaskGid, name: 'Test Task' } }
});
// Mock section addition
requestUrl.mockResolvedValueOnce({
status: 200,
json: { data: { gid: mockTaskGid } }
});
// Mock task details fetch
const mockTaskDetails = {
gid: mockTaskGid,
name: 'Test Task',
permalink_url: 'https://app.asana.com/0/1/1'
};
requestUrl.mockResolvedValueOnce({
status: 200,
json: { data: mockTaskDetails }
});
const result = await createTaskInAsana(
'Test Task',
'workspace-1',
'project-1',
'section-1',
mockSettings
);
expect(result).toEqual(mockTaskDetails);
expect(requestUrl).toHaveBeenCalledTimes(3);
// Verify task creation call
expect(requestUrl).toHaveBeenNthCalledWith(1, {
url: 'https://app.asana.com/api/1.0/tasks',
method: 'POST',
headers: {
Authorization: 'Bearer test-token',
'Content-Type': 'application/json'
},
body: JSON.stringify({
data: {
name: 'Test Task',
workspace: 'workspace-1',
projects: ['project-1'],
assignee: undefined
}
})
});
// Verify section addition call
expect(requestUrl).toHaveBeenNthCalledWith(2, {
url: 'https://app.asana.com/api/1.0/sections/section-1/addTask',
method: 'POST',
headers: {
Authorization: 'Bearer test-token',
'Content-Type': 'application/json'
},
body: JSON.stringify({
data: {
task: mockTaskGid
}
})
});
// Verify task details fetch call
expect(requestUrl).toHaveBeenNthCalledWith(3, {
url: `https://app.asana.com/api/1.0/tasks/${mockTaskGid}`,
method: 'GET',
headers: {
Authorization: 'Bearer test-token'
}
});
});
test('createTaskInAsana handles error response', async () => {
requestUrl.mockRejectedValueOnce(new Error('API Error'));
await expect(createTaskInAsana(
'Test Task',
'workspace-1',
'project-1',
'section-1',
mockSettings
)).rejects.toThrow('Failed to create task in Asana');
});
});

52
tests/main.test.js Normal file
View file

@ -0,0 +1,52 @@
const { App, Plugin } = require('obsidian');
const AsanaPlugin = require('../main').default;
describe('Asana Plugin', () => {
let plugin;
let app;
beforeEach(async () => {
app = new App();
plugin = new AsanaPlugin(app, {});
await plugin.loadSettings();
});
afterEach(() => {
if (plugin.onunload) {
plugin.onunload();
}
});
test('Plugin loads successfully', () => {
expect(plugin).toBeDefined();
expect(plugin.settings).toBeDefined();
});
test('Default settings are set correctly', () => {
expect(plugin.settings.asanaToken).toBe('');
expect(plugin.settings.markTaskAsCompleted).toBe(false);
expect(plugin.settings.pinnedProjects).toEqual([]);
expect(plugin.settings.enableMarkdownLink).toBe(true);
expect(plugin.settings.showArchivedProjects).toBe(false);
expect(plugin.settings.pinMyTasks).toBe(true);
});
test('Settings can be saved and loaded', async () => {
const testSettings = {
asanaToken: 'test-token',
markTaskAsCompleted: true,
pinnedProjects: ['Project 1', 'Project 2'],
enableMarkdownLink: false,
showArchivedProjects: true,
pinMyTasks: false
};
// Mock the loadData method to return our test settings
plugin.loadData = jest.fn().mockResolvedValue(testSettings);
// Load the settings
await plugin.loadSettings();
expect(plugin.settings).toEqual(testSettings);
});
});

16
tests/setup.js Normal file
View file

@ -0,0 +1,16 @@
// Mock the global window object
global.window = {
document: {
createElement: jest.fn(),
createDocumentFragment: jest.fn()
}
};
// Mock the global document object
global.document = {
createElement: jest.fn(),
createDocumentFragment: jest.fn()
};
// Mock console.error to prevent test output pollution
console.error = jest.fn();