mirror of
https://github.com/mryanb/obsidian-asana.git
synced 2026-07-22 05:38:24 +00:00
Compare commits
15 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b425a7b952 | ||
|
|
e80e621e56 | ||
|
|
8ff3a1a9d7 | ||
|
|
2bbd595640 | ||
|
|
78dd73fae3 | ||
|
|
128ac55d20 | ||
|
|
8144510153 | ||
|
|
a8d0e4945d | ||
|
|
b4e03e57af | ||
|
|
5e4b30ca91 | ||
|
|
7de5392905 | ||
|
|
b0d36bff77 | ||
|
|
5a35b56144 | ||
|
|
1ec59d04c2 | ||
|
|
41287abd76 |
13 changed files with 4438 additions and 58 deletions
|
|
@ -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.
|
||||

|
||||
|
||||
## 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
21
jest.config.js
Normal 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/'
|
||||
]
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "asana-for-obsidian",
|
||||
"id": "asana",
|
||||
"name": "Asana",
|
||||
"version": "0.1.2",
|
||||
"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
3827
package-lock.json
generated
File diff suppressed because it is too large
Load diff
12
package.json
12
package.json
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
67
src/main.ts
67
src/main.ts
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
115
tests/__mocks__/obsidian.js
Normal 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
155
tests/api.test.js
Normal 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
52
tests/main.test.js
Normal 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
16
tests/setup.js
Normal 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();
|
||||
Loading…
Reference in a new issue