Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions extensions/asana/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Asana Changelog

## [Add subtasks support and tag management] - 2026-01-23

- Added ability to view subtasks for a task in the detail view
- Added action to create subtasks under a parent task
- Added ability to convert tasks to subtasks and subtasks to tasks
- Added actions to add and remove tags from existing tasks
- Added ability to rename tasks and subtasks

## [Add support for Asana sections] - 2025-10-28

- Added option for assigning a section when creating a task. User can select from a list of existing sections.
Expand Down
21 changes: 21 additions & 0 deletions extensions/asana/src/api/tags.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { request } from "./request";
import { Task } from "./tasks";

export type Tag = {
gid: string;
Expand All @@ -12,3 +13,23 @@ export async function getTagsForWorkspace(workspaceGid: string) {

return data.data;
}

export async function addTag(taskId: string, tagId: string) {
const payload = { tag: tagId };
const { data } = await request<{ data: Task }>(`/tasks/${taskId}/addTag`, {
method: "POST",
data: { data: payload },
});

return data.data;
}

export async function removeTag(taskId: string, tagId: string) {
const payload = { tag: tagId };
const { data } = await request<{ data: Task }>(`/tasks/${taskId}/removeTag`, {
method: "POST",
data: { data: payload },
});

return data.data;
}
69 changes: 68 additions & 1 deletion extensions/asana/src/api/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ type Tag = {
name: string;
};

type Parent = {
gid: string;
name: string;
};

export type Task = {
gid: string;
id: string;
Expand All @@ -60,10 +65,11 @@ export type Task = {
custom_fields: CustomField[];
memberships: Membership[];
tags: Tag[];
parent: Parent | null;
};

const taskFields =
"id,name,due_on,due_at,start_on,completed,projects.name,projects.color,assignee_section.name,permalink_url,custom_fields,assignee.name,memberships.project.name,memberships.section.name,tags.name";
"id,name,due_on,due_at,start_on,completed,projects.name,projects.color,assignee_section.name,permalink_url,custom_fields,assignee.name,memberships.project.name,memberships.section.name,tags.name,parent.name";

export async function getMyTasks(workspace: string, showCompletedTasks: boolean) {
const {
Expand Down Expand Up @@ -129,6 +135,7 @@ type UpdateTaskPayload = Partial<{
assignee: string | null;
due_on: Date | null;
custom_fields: Record<string, string | null>;
name: string;
}>;

export async function updateTask(taskId: string, payload: UpdateTaskPayload) {
Expand All @@ -145,3 +152,63 @@ export async function deleteTask(taskId: string) {
method: "DELETE",
});
}

const subtaskFields =
"gid,name,due_on,due_at,start_on,completed,projects.name,projects.color,assignee_section.name,permalink_url,custom_fields,assignee.name,memberships.project.name,memberships.section.name,tags.name,parent.name";

export async function getSubtasks(taskId: string) {
const { data } = await request<{ data: Task[] }>(`/tasks/${taskId}/subtasks`, {
params: {
opt_fields: subtaskFields,
},
});

return data.data;
}

export async function setTaskParent(taskId: string, parentId: string) {
const { data } = await request<{ data: Task }>(`/tasks/${taskId}/setParent`, {
method: "POST",
data: { data: { parent: parentId } },
});
return data.data;
}

export async function removeTaskParent(taskId: string) {
const { data } = await request<{ data: Task }>(`/tasks/${taskId}/setParent`, {
method: "POST",
data: { data: { parent: null } },
});
return data.data;
}

export async function searchTasks(workspace: string, query?: string) {
const { data } = await request<{ data: Pick<Task, "gid" | "name" | "parent">[] }>(
`/workspaces/${workspace}/typeahead`,
{
params: {
resource_type: "task",
query: query || "",
opt_fields: "gid,name,parent",
},
},
);
// Only return top-level tasks (not subtasks)
return data.data.filter((task) => !task.parent);
}

export type SubtaskPayload = {
name: string;
assignee?: string;
due_on?: string;
html_notes?: string;
memberships?: { project: string; section?: string }[];
};

export async function createSubtask(parentId: string, payload: SubtaskPayload) {
const { data } = await request<{ data: Task }>(`/tasks/${parentId}/subtasks`, {
method: "POST",
data: { data: payload },
});
return data.data;
}
139 changes: 139 additions & 0 deletions extensions/asana/src/components/CreateSubtaskForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { Action, ActionPanel, Form, Icon, useNavigation, Toast, showToast } from "@raycast/api";
import { format } from "date-fns";
import { FormValidation, getAvatarIcon, useForm, MutatePromise } from "@raycast/utils";
import { useUsers } from "../hooks/useUsers";
import { useMe } from "../hooks/useMe";
import { useSections } from "../hooks/useSections";
import { getErrorMessage } from "../helpers/errors";
import { escapeHtml } from "../helpers/task";
import TaskDetail from "./TaskDetail";
import { Task, createSubtask, SubtaskPayload } from "../api/tasks";

type SubtaskFormValues = {
name: string;
description: string;
assignee: string;
due_date: Date | null;
section: string;
};

type CreateSubtaskFormProps = {
parentTask: Task;
workspace?: string;
mutateSubtasks?: MutatePromise<Task[] | undefined>;
};

export default function CreateSubtaskForm({ parentTask, workspace, mutateSubtasks }: CreateSubtaskFormProps) {
const { push } = useNavigation();

const { data: users, isLoading: isLoadingUsers } = useUsers(workspace);
const { data: me, isLoading: isLoadingMe } = useMe();

const parentProjectId = parentTask.projects[0]?.gid;
const { data: sections, isLoading: isLoadingSections } = useSections(parentProjectId);

const { handleSubmit, itemProps, reset, focus } = useForm<SubtaskFormValues>({
async onSubmit(values) {
const toast = await showToast({ style: Toast.Style.Animated, title: "Creating subtask" });

try {
const payload: SubtaskPayload = {
name: values.name,
...(values.description ? { html_notes: `<body>${escapeHtml(values.description)}</body>` } : {}),
...(values.assignee ? { assignee: values.assignee } : {}),
...(values.due_date ? { due_on: format(values.due_date, "yyyy-MM-dd") } : {}),
...(parentProjectId
? {
memberships: [{ project: parentProjectId, ...(values.section ? { section: values.section } : {}) }],
}
: {}),
};

const subtask = await createSubtask(parentTask.gid, payload);

if (mutateSubtasks) {
mutateSubtasks();
}

toast.style = Toast.Style.Success;
toast.title = "Created subtask";

toast.primaryAction = {
title: "Open Subtask",
shortcut: { modifiers: ["cmd", "shift"], key: "o" },
onAction: () => push(<TaskDetail task={subtask} workspace={workspace} />),
};

reset({
name: "",
description: "",
due_date: null,
assignee: values.assignee,
section: values.section,
});

focus("name");
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Failed to create subtask";
toast.message = getErrorMessage(error);
}
},
validation: {
name: FormValidation.Required,
},
initialValues: {
name: "",
description: "",
assignee: "",
due_date: null,
section: "",
},
});

const projectInfo = parentTask.projects.length > 0 ? parentTask.projects.map((p) => p.name).join(", ") : "No project";

return (
<Form
navigationTitle={`Add subtask to "${parentTask.name}"`}
isLoading={isLoadingUsers || isLoadingMe || isLoadingSections}
actions={
<ActionPanel>
<Action.SubmitForm title="Create Subtask" onSubmit={handleSubmit} />
</ActionPanel>
}
>
<Form.Description title="Parent Task" text={parentTask.name} />
<Form.Description title="Project" text={projectInfo} />

{parentProjectId && sections && sections.length > 0 && (
<Form.Dropdown title="Section" {...itemProps.section}>
<Form.Dropdown.Item title="No section" value="" icon={Icon.Tray} />
{sections.map((section) => (
<Form.Dropdown.Item key={section.gid} value={section.gid} title={section.name} icon={Icon.Tray} />
))}
</Form.Dropdown>
)}

<Form.Separator />

<Form.TextField title="Subtask Name" placeholder="What needs to be done?" autoFocus {...itemProps.name} />

<Form.TextArea title="Description" placeholder="Add more detail (optional)" {...itemProps.description} />

<Form.Dropdown title="Assignee" {...itemProps.assignee}>
<Form.Dropdown.Item title="Unassigned" value="" icon={Icon.Person} />
{users?.map((user) => (
<Form.Dropdown.Item
key={user.gid}
value={user.gid}
title={user.gid === me?.gid ? `${user.name} (me)` : user.name}
icon={getAvatarIcon(user.name)}
/>
))}
</Form.Dropdown>

<Form.DatePicker title="Due Date" type={Form.DatePicker.Type.Date} {...itemProps.due_date} />
</Form>
);
}
3 changes: 2 additions & 1 deletion extensions/asana/src/components/CreateTaskForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useTags } from "../hooks/useTags";
import { getErrorMessage } from "../helpers/errors";
import { TaskFormValues } from "../create-task";
import { getProjectIcon } from "../helpers/project";
import { escapeHtml } from "../helpers/task";
import TaskDetail from "./TaskDetail";
import { createTask, TaskPayload } from "../api/tasks";
import { asanaToRaycastColor } from "../helpers/colors";
Expand All @@ -42,7 +43,7 @@ export default function CreateTaskForm(props: {
const toast = await showToast({ style: Toast.Style.Animated, title: "Creating task" });

try {
const htmlNotes = `<body>${values.description}</body>`;
const htmlNotes = `<body>${escapeHtml(values.description)}</body>`;

const customFieldsEntries = Object.entries(values).filter(
([key, value]) => key.startsWith("field-") && value !== "",
Expand Down
91 changes: 91 additions & 0 deletions extensions/asana/src/components/ParentTaskPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { useState } from "react";
import { List, Action, ActionPanel, Icon, showToast, Toast, useNavigation } from "@raycast/api";
import { MutatePromise } from "@raycast/utils";
import { Task, setTaskParent } from "../api/tasks";
import { useSearchTasks } from "../hooks/useSearchTasks";
import { getErrorMessage } from "../helpers/errors";

type ParentTaskPickerProps = {
task: Task;
workspace: string;
mutateList?: MutatePromise<Task[] | undefined>;
mutateDetail?: MutatePromise<Task>;
};

export default function ParentTaskPicker({ task, workspace, mutateList, mutateDetail }: ParentTaskPickerProps) {
const { pop } = useNavigation();
const [searchText, setSearchText] = useState("");
const { data: tasks, isLoading } = useSearchTasks(workspace, searchText);

// Filter out the current task from potential parents
const availableTasks = tasks?.filter((t) => t.gid !== task.gid);

async function selectParent(parentTask: Pick<Task, "gid" | "name">) {
try {
await showToast({ style: Toast.Style.Animated, title: "Converting to subtask" });

const asyncUpdate = setTaskParent(task.gid, parentTask.gid);

if (mutateList) {
mutateList(asyncUpdate, {
optimisticUpdate(data) {
if (!data) {
return;
}
return data.map((t) =>
t.gid === task.gid ? { ...t, parent: { gid: parentTask.gid, name: parentTask.name } } : t,
);
},
});
}

if (mutateDetail) {
mutateDetail(asyncUpdate, {
optimisticUpdate(data) {
return { ...data, parent: { gid: parentTask.gid, name: parentTask.name } };
},
});
}

await asyncUpdate;

await showToast({
style: Toast.Style.Success,
title: "Converted to subtask",
message: `Now a subtask of "${parentTask.name}"`,
});

pop();
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Failed to convert to subtask",
message: getErrorMessage(error),
});
}
}

return (
<List
navigationTitle={`Select parent for "${task.name}"`}
searchBarPlaceholder="Search for a task..."
onSearchTextChange={setSearchText}
isLoading={isLoading}
throttle
>
<List.EmptyView title="No tasks found" description="Try a different search term" />
{availableTasks?.map((parentTask) => (
<List.Item
key={parentTask.gid}
title={parentTask.name}
icon={Icon.Circle}
actions={
<ActionPanel>
<Action title="Set as Parent" icon={Icon.ArrowUp} onAction={() => selectParent(parentTask)} />
</ActionPanel>
}
/>
))}
</List>
);
}
Loading