Skip to content

Commit d970c4b

Browse files
abcd-cagreptile-apps[bot]pernielsentikaerraycastbot
authored
Feature/tags and subtasks (#24346)
* add/remove tags Add a "Change Tags" submenu to task actions that allows users to add or remove existing workspace tags from tasks. * view subtasks for a task subtasks have same actions as tasks * add subtask, convert between task/subtask Add three new actions: 1. "Convert to Subtask" - For tasks only, pick a parent task 2. "Convert to Task" - For subtasks only, remove parent relationship 3. "Add Subtask" - For tasks only, create subtask with inherited project/section and assignee option * can now rename a task/subtask * Updated changelog * Update extensions/asana/src/components/TaskDetail.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * refinements, suggested by Greptile bot * prettier * fix orphaned subtask * create task bug fix putting < and > characters in the description field would result in a 400 * Apply suggestion from @greptile-apps[bot] Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update CHANGELOG.md --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Per Nielsen Tikær <per@raycast.com> Co-authored-by: raycastbot <bot@raycast.com>
1 parent 1f1e40a commit d970c4b

File tree

14 files changed

+682
-9
lines changed

14 files changed

+682
-9
lines changed

extensions/asana/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Asana Changelog
22

3+
## [Add subtasks support and tag management] - 2026-01-23
4+
5+
- Added ability to view subtasks for a task in the detail view
6+
- Added action to create subtasks under a parent task
7+
- Added ability to convert tasks to subtasks and subtasks to tasks
8+
- Added actions to add and remove tags from existing tasks
9+
- Added ability to rename tasks and subtasks
10+
311
## [Add support for Asana sections] - 2025-10-28
412

513
- Added option for assigning a section when creating a task. User can select from a list of existing sections.

extensions/asana/src/api/tags.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { request } from "./request";
2+
import { Task } from "./tasks";
23

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

1314
return data.data;
1415
}
16+
17+
export async function addTag(taskId: string, tagId: string) {
18+
const payload = { tag: tagId };
19+
const { data } = await request<{ data: Task }>(`/tasks/${taskId}/addTag`, {
20+
method: "POST",
21+
data: { data: payload },
22+
});
23+
24+
return data.data;
25+
}
26+
27+
export async function removeTag(taskId: string, tagId: string) {
28+
const payload = { tag: tagId };
29+
const { data } = await request<{ data: Task }>(`/tasks/${taskId}/removeTag`, {
30+
method: "POST",
31+
data: { data: payload },
32+
});
33+
34+
return data.data;
35+
}

extensions/asana/src/api/tasks.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ type Tag = {
4545
name: string;
4646
};
4747

48+
type Parent = {
49+
gid: string;
50+
name: string;
51+
};
52+
4853
export type Task = {
4954
gid: string;
5055
id: string;
@@ -60,10 +65,11 @@ export type Task = {
6065
custom_fields: CustomField[];
6166
memberships: Membership[];
6267
tags: Tag[];
68+
parent: Parent | null;
6369
};
6470

6571
const taskFields =
66-
"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";
72+
"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";
6773

6874
export async function getMyTasks(workspace: string, showCompletedTasks: boolean) {
6975
const {
@@ -129,6 +135,7 @@ type UpdateTaskPayload = Partial<{
129135
assignee: string | null;
130136
due_on: Date | null;
131137
custom_fields: Record<string, string | null>;
138+
name: string;
132139
}>;
133140

134141
export async function updateTask(taskId: string, payload: UpdateTaskPayload) {
@@ -145,3 +152,63 @@ export async function deleteTask(taskId: string) {
145152
method: "DELETE",
146153
});
147154
}
155+
156+
const subtaskFields =
157+
"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";
158+
159+
export async function getSubtasks(taskId: string) {
160+
const { data } = await request<{ data: Task[] }>(`/tasks/${taskId}/subtasks`, {
161+
params: {
162+
opt_fields: subtaskFields,
163+
},
164+
});
165+
166+
return data.data;
167+
}
168+
169+
export async function setTaskParent(taskId: string, parentId: string) {
170+
const { data } = await request<{ data: Task }>(`/tasks/${taskId}/setParent`, {
171+
method: "POST",
172+
data: { data: { parent: parentId } },
173+
});
174+
return data.data;
175+
}
176+
177+
export async function removeTaskParent(taskId: string) {
178+
const { data } = await request<{ data: Task }>(`/tasks/${taskId}/setParent`, {
179+
method: "POST",
180+
data: { data: { parent: null } },
181+
});
182+
return data.data;
183+
}
184+
185+
export async function searchTasks(workspace: string, query?: string) {
186+
const { data } = await request<{ data: Pick<Task, "gid" | "name" | "parent">[] }>(
187+
`/workspaces/${workspace}/typeahead`,
188+
{
189+
params: {
190+
resource_type: "task",
191+
query: query || "",
192+
opt_fields: "gid,name,parent",
193+
},
194+
},
195+
);
196+
// Only return top-level tasks (not subtasks)
197+
return data.data.filter((task) => !task.parent);
198+
}
199+
200+
export type SubtaskPayload = {
201+
name: string;
202+
assignee?: string;
203+
due_on?: string;
204+
html_notes?: string;
205+
memberships?: { project: string; section?: string }[];
206+
};
207+
208+
export async function createSubtask(parentId: string, payload: SubtaskPayload) {
209+
const { data } = await request<{ data: Task }>(`/tasks/${parentId}/subtasks`, {
210+
method: "POST",
211+
data: { data: payload },
212+
});
213+
return data.data;
214+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { Action, ActionPanel, Form, Icon, useNavigation, Toast, showToast } from "@raycast/api";
2+
import { format } from "date-fns";
3+
import { FormValidation, getAvatarIcon, useForm, MutatePromise } from "@raycast/utils";
4+
import { useUsers } from "../hooks/useUsers";
5+
import { useMe } from "../hooks/useMe";
6+
import { useSections } from "../hooks/useSections";
7+
import { getErrorMessage } from "../helpers/errors";
8+
import { escapeHtml } from "../helpers/task";
9+
import TaskDetail from "./TaskDetail";
10+
import { Task, createSubtask, SubtaskPayload } from "../api/tasks";
11+
12+
type SubtaskFormValues = {
13+
name: string;
14+
description: string;
15+
assignee: string;
16+
due_date: Date | null;
17+
section: string;
18+
};
19+
20+
type CreateSubtaskFormProps = {
21+
parentTask: Task;
22+
workspace?: string;
23+
mutateSubtasks?: MutatePromise<Task[] | undefined>;
24+
};
25+
26+
export default function CreateSubtaskForm({ parentTask, workspace, mutateSubtasks }: CreateSubtaskFormProps) {
27+
const { push } = useNavigation();
28+
29+
const { data: users, isLoading: isLoadingUsers } = useUsers(workspace);
30+
const { data: me, isLoading: isLoadingMe } = useMe();
31+
32+
const parentProjectId = parentTask.projects[0]?.gid;
33+
const { data: sections, isLoading: isLoadingSections } = useSections(parentProjectId);
34+
35+
const { handleSubmit, itemProps, reset, focus } = useForm<SubtaskFormValues>({
36+
async onSubmit(values) {
37+
const toast = await showToast({ style: Toast.Style.Animated, title: "Creating subtask" });
38+
39+
try {
40+
const payload: SubtaskPayload = {
41+
name: values.name,
42+
...(values.description ? { html_notes: `<body>${escapeHtml(values.description)}</body>` } : {}),
43+
...(values.assignee ? { assignee: values.assignee } : {}),
44+
...(values.due_date ? { due_on: format(values.due_date, "yyyy-MM-dd") } : {}),
45+
...(parentProjectId
46+
? {
47+
memberships: [{ project: parentProjectId, ...(values.section ? { section: values.section } : {}) }],
48+
}
49+
: {}),
50+
};
51+
52+
const subtask = await createSubtask(parentTask.gid, payload);
53+
54+
if (mutateSubtasks) {
55+
mutateSubtasks();
56+
}
57+
58+
toast.style = Toast.Style.Success;
59+
toast.title = "Created subtask";
60+
61+
toast.primaryAction = {
62+
title: "Open Subtask",
63+
shortcut: { modifiers: ["cmd", "shift"], key: "o" },
64+
onAction: () => push(<TaskDetail task={subtask} workspace={workspace} />),
65+
};
66+
67+
reset({
68+
name: "",
69+
description: "",
70+
due_date: null,
71+
assignee: values.assignee,
72+
section: values.section,
73+
});
74+
75+
focus("name");
76+
} catch (error) {
77+
toast.style = Toast.Style.Failure;
78+
toast.title = "Failed to create subtask";
79+
toast.message = getErrorMessage(error);
80+
}
81+
},
82+
validation: {
83+
name: FormValidation.Required,
84+
},
85+
initialValues: {
86+
name: "",
87+
description: "",
88+
assignee: "",
89+
due_date: null,
90+
section: "",
91+
},
92+
});
93+
94+
const projectInfo = parentTask.projects.length > 0 ? parentTask.projects.map((p) => p.name).join(", ") : "No project";
95+
96+
return (
97+
<Form
98+
navigationTitle={`Add subtask to "${parentTask.name}"`}
99+
isLoading={isLoadingUsers || isLoadingMe || isLoadingSections}
100+
actions={
101+
<ActionPanel>
102+
<Action.SubmitForm title="Create Subtask" onSubmit={handleSubmit} />
103+
</ActionPanel>
104+
}
105+
>
106+
<Form.Description title="Parent Task" text={parentTask.name} />
107+
<Form.Description title="Project" text={projectInfo} />
108+
109+
{parentProjectId && sections && sections.length > 0 && (
110+
<Form.Dropdown title="Section" {...itemProps.section}>
111+
<Form.Dropdown.Item title="No section" value="" icon={Icon.Tray} />
112+
{sections.map((section) => (
113+
<Form.Dropdown.Item key={section.gid} value={section.gid} title={section.name} icon={Icon.Tray} />
114+
))}
115+
</Form.Dropdown>
116+
)}
117+
118+
<Form.Separator />
119+
120+
<Form.TextField title="Subtask Name" placeholder="What needs to be done?" autoFocus {...itemProps.name} />
121+
122+
<Form.TextArea title="Description" placeholder="Add more detail (optional)" {...itemProps.description} />
123+
124+
<Form.Dropdown title="Assignee" {...itemProps.assignee}>
125+
<Form.Dropdown.Item title="Unassigned" value="" icon={Icon.Person} />
126+
{users?.map((user) => (
127+
<Form.Dropdown.Item
128+
key={user.gid}
129+
value={user.gid}
130+
title={user.gid === me?.gid ? `${user.name} (me)` : user.name}
131+
icon={getAvatarIcon(user.name)}
132+
/>
133+
))}
134+
</Form.Dropdown>
135+
136+
<Form.DatePicker title="Due Date" type={Form.DatePicker.Type.Date} {...itemProps.due_date} />
137+
</Form>
138+
);
139+
}

extensions/asana/src/components/CreateTaskForm.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { useTags } from "../hooks/useTags";
2222
import { getErrorMessage } from "../helpers/errors";
2323
import { TaskFormValues } from "../create-task";
2424
import { getProjectIcon } from "../helpers/project";
25+
import { escapeHtml } from "../helpers/task";
2526
import TaskDetail from "./TaskDetail";
2627
import { createTask, TaskPayload } from "../api/tasks";
2728
import { asanaToRaycastColor } from "../helpers/colors";
@@ -42,7 +43,7 @@ export default function CreateTaskForm(props: {
4243
const toast = await showToast({ style: Toast.Style.Animated, title: "Creating task" });
4344

4445
try {
45-
const htmlNotes = `<body>${values.description}</body>`;
46+
const htmlNotes = `<body>${escapeHtml(values.description)}</body>`;
4647

4748
const customFieldsEntries = Object.entries(values).filter(
4849
([key, value]) => key.startsWith("field-") && value !== "",
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { useState } from "react";
2+
import { List, Action, ActionPanel, Icon, showToast, Toast, useNavigation } from "@raycast/api";
3+
import { MutatePromise } from "@raycast/utils";
4+
import { Task, setTaskParent } from "../api/tasks";
5+
import { useSearchTasks } from "../hooks/useSearchTasks";
6+
import { getErrorMessage } from "../helpers/errors";
7+
8+
type ParentTaskPickerProps = {
9+
task: Task;
10+
workspace: string;
11+
mutateList?: MutatePromise<Task[] | undefined>;
12+
mutateDetail?: MutatePromise<Task>;
13+
};
14+
15+
export default function ParentTaskPicker({ task, workspace, mutateList, mutateDetail }: ParentTaskPickerProps) {
16+
const { pop } = useNavigation();
17+
const [searchText, setSearchText] = useState("");
18+
const { data: tasks, isLoading } = useSearchTasks(workspace, searchText);
19+
20+
// Filter out the current task from potential parents
21+
const availableTasks = tasks?.filter((t) => t.gid !== task.gid);
22+
23+
async function selectParent(parentTask: Pick<Task, "gid" | "name">) {
24+
try {
25+
await showToast({ style: Toast.Style.Animated, title: "Converting to subtask" });
26+
27+
const asyncUpdate = setTaskParent(task.gid, parentTask.gid);
28+
29+
if (mutateList) {
30+
mutateList(asyncUpdate, {
31+
optimisticUpdate(data) {
32+
if (!data) {
33+
return;
34+
}
35+
return data.map((t) =>
36+
t.gid === task.gid ? { ...t, parent: { gid: parentTask.gid, name: parentTask.name } } : t,
37+
);
38+
},
39+
});
40+
}
41+
42+
if (mutateDetail) {
43+
mutateDetail(asyncUpdate, {
44+
optimisticUpdate(data) {
45+
return { ...data, parent: { gid: parentTask.gid, name: parentTask.name } };
46+
},
47+
});
48+
}
49+
50+
await asyncUpdate;
51+
52+
await showToast({
53+
style: Toast.Style.Success,
54+
title: "Converted to subtask",
55+
message: `Now a subtask of "${parentTask.name}"`,
56+
});
57+
58+
pop();
59+
} catch (error) {
60+
await showToast({
61+
style: Toast.Style.Failure,
62+
title: "Failed to convert to subtask",
63+
message: getErrorMessage(error),
64+
});
65+
}
66+
}
67+
68+
return (
69+
<List
70+
navigationTitle={`Select parent for "${task.name}"`}
71+
searchBarPlaceholder="Search for a task..."
72+
onSearchTextChange={setSearchText}
73+
isLoading={isLoading}
74+
throttle
75+
>
76+
<List.EmptyView title="No tasks found" description="Try a different search term" />
77+
{availableTasks?.map((parentTask) => (
78+
<List.Item
79+
key={parentTask.gid}
80+
title={parentTask.name}
81+
icon={Icon.Circle}
82+
actions={
83+
<ActionPanel>
84+
<Action title="Set as Parent" icon={Icon.ArrowUp} onAction={() => selectParent(parentTask)} />
85+
</ActionPanel>
86+
}
87+
/>
88+
))}
89+
</List>
90+
);
91+
}

0 commit comments

Comments
 (0)