Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add child issues to Jira extension #15204

Merged
merged 18 commits into from
Nov 15, 2024
Merged
Show file tree
Hide file tree
Changes from 14 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/jira/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Jira Changelog

## [Fixed issue with child issues not updating UI] - 2024-11-04

- Resolved an issue where the child’s status updates were not reflected in the user interface upon changing their status.

## [Added Child Issues support] - 2024-11-02

- Implemented the ability to view and manage child-related issues within the issue detail view, as well as a new component that provides a comprehensive list of all current issue child issues.

## [Fix number search to include issue keys for all projects.] - 2024-09-30

- When a user searches for a number without a project selected, the extension matches the number against issue keys in all projects.
Expand Down
3 changes: 2 additions & 1 deletion extensions/jira/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"literallyjustroy",
"mheidinger",
"mikybars",
"luarmr"
"luarmr",
"horumy"
],
"pastContributors": [
"igor9silva"
Expand Down
3 changes: 2 additions & 1 deletion extensions/jira/src/api/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export type Issue = {
updated: string;
status: IssueStatus;
watches: IssueWatches;
subtasks?: Issue[];
};
};

Expand All @@ -155,7 +156,7 @@ type GetIssuesResponse = {

export async function getIssues({ jql } = { jql: "" }) {
const params = {
fields: "summary,updated,issuetype,status,priority,assignee,project,watches",
fields: "summary,updated,issuetype,status,priority,assignee,project,watches,subtasks",
startAt: "0",
maxResults: "200",
validateQuery: "warn",
Expand Down
11 changes: 11 additions & 0 deletions extensions/jira/src/components/IssueActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { slugify } from "../helpers/string";

import CreateIssueForm from "./CreateIssueForm";
import IssueAttachments from "./IssueAttachments";
import IssueChildIssues from "./IssueChildIssues";
import IssueCommentForm from "./IssueCommentForm";
import IssueComments from "./IssueComments";
import IssueDetail from "./IssueDetail";
Expand All @@ -33,6 +34,7 @@ type IssueActionsProps = {
mutate?: MutatePromise<Issue[] | undefined>;
mutateDetail?: MutatePromise<Issue | TIssueDetail | null>;
showDetailsAction?: boolean;
showChildIssuesAction?: boolean;
showAttachmentsAction?: boolean;
};

Expand All @@ -46,6 +48,7 @@ export default function IssueActions({
mutate,
mutateDetail,
showDetailsAction,
showChildIssuesAction,
showAttachmentsAction,
}: IssueActionsProps) {
const { siteUrl, myself } = getJiraCredentials();
Expand Down Expand Up @@ -166,6 +169,14 @@ export default function IssueActions({
</ActionPanel.Section>

<ActionPanel.Section>
{showChildIssuesAction && (
<Action.Push
target={<IssueChildIssues issue={issue} />}
title="Open Child Issues"
icon={Icon.Tree}
shortcut={{ modifiers: ["cmd", "shift"], key: "o" }}
/>
)}
<ChangePrioritySubmenu issue={issue} mutate={mutateWithOptimisticUpdate} />

<ChangeAssigneeSubmenu issue={issue} mutate={mutateWithOptimisticUpdate} />
Expand Down
58 changes: 58 additions & 0 deletions extensions/jira/src/components/IssueChildIssues.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useMemo } from "react";

import { type Issue, type IssueDetail } from "../api/issues";
import useIssues, { useEpicIssues } from "../hooks/useIssues";

import StatusIssueList from "./StatusIssueList";

export default function IssueChildIssues({ issue }: { issue: Issue }) {
const { mutate: mutateEpicIssues } = useEpicIssues(issue?.id ?? "");

// Only create JQL if there are subtask
const subtaskJql = useMemo(() => {
if (!issue.fields.subtasks?.length) return "";
const subtaskIds = issue.fields.subtasks.map((subtask) => subtask.id);
return `issue in (${subtaskIds.join(",")})`;
}, [issue.fields.subtasks]);

const {
issues: subtasks,
isLoading: isLoadingSubtasks,
mutate: mutateSubtasks,
} = useIssues(
subtaskJql || "issue = null", // Provide valid JQL even when no subtasks
);

const isEpic = issue.fields.issuetype?.name === "Epic";
const { issues: epicIssues, isLoading: isLoadingEpicIssues } = useEpicIssues(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why having two useEpicIssues in the component? Can't they be combined into one?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, fixed on latest commit! :)

isEpic ? issue.id : "", // Only fetch epic issues for epics
);

// Memoize the combined and filtered child issues
const childIssues = useMemo(() => {
const allIssues = [...(subtasks || []), ...(epicIssues || [])];
// Ensure unique keys by using issue ID
return allIssues
.filter((issue): issue is IssueDetail => issue !== null)
.map((issue) => ({
...issue,
key: `${issue.key}`, // Ensure unique keys
}));
}, [subtasks, epicIssues]);

const isLoading = isLoadingSubtasks || isLoadingEpicIssues;

return (
<StatusIssueList
issues={childIssues}
isLoading={isLoading}
mutate={async (data) => {
if (isEpic) {
return mutateEpicIssues(data);
}
// For subtasks, we need to mutate the subtasks data
return mutateSubtasks(data);
}}
/>
);
}
40 changes: 33 additions & 7 deletions extensions/jira/src/components/IssueDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getAuthenticatedUri, getBaseUrl } from "../api/request";
import { getProjectAvatar, getUserAvatar } from "../helpers/avatars";
import { formatDate, getCustomFieldsForDetail, getMarkdownFromHtml, getStatusColor } from "../helpers/issues";
import { replaceAsync } from "../helpers/string";
import { useEpicIssues } from "../hooks/useIssues";

import IssueActions from "./IssueActions";
import IssueDetailCustomFields from "./IssueDetailCustomFields";
Expand All @@ -28,7 +29,6 @@ export default function IssueDetail({ initialIssue, issueKey }: IssueDetailProps
if (!issue) {
return null;
}

const baseUrl = getBaseUrl();
const description = issue.renderedFields?.description ?? "";
// Resolve all the image URLs to data URIs in the cached promise for better performance
Expand All @@ -45,36 +45,53 @@ export default function IssueDetail({ initialIssue, issueKey }: IssueDetailProps
{ initialData: initialIssue },
);

const { issues: epicIssues, isLoading: isLoadingEpicIssues } = useEpicIssues(issue?.key ?? "");

const attachments = issue?.fields.attachment;
const numberOfAttachments = attachments?.length ?? 0;
const hasAttachments = numberOfAttachments > 0;

const hasChildIssues =
(issue?.fields.subtasks && issue.fields.subtasks.length > 0) || (epicIssues && epicIssues.length > 0);
const { customMarkdownFields, customMetadataFields } = useMemo(() => getCustomFieldsForDetail(issue), [issue]);

const markdown = useMemo(() => {
if (!issue) {
return "";
}

let markdown = `# ${issue.fields.summary}`;
let markdown = `# ${issue.fields.summary} \n\n`;
const description = issue.renderedFields?.description;

if (description) {
markdown += `\n\n${getMarkdownFromHtml(description)}`;
}

if (issue.fields.issuetype && issue.fields.issuetype.name === "Epic" && epicIssues) {
markdown += "\n\n## Child Issues\n";
epicIssues.forEach((childIssue) => {
markdown += `- ${childIssue.key} - ${childIssue.fields.summary}\n`;
});
}

const subtasks = issue.fields.subtasks;
if (subtasks && subtasks.length > 0) {
markdown += "\n\n## Child Issues\n";
subtasks.forEach((subtask) => {
markdown += `- ${subtask.key} - ${subtask.fields.summary}\n`;
});
}

customMarkdownFields.forEach((markdownField) => {
markdown += markdownField;
});

return markdown;
}, [issue]);
}, [issue, epicIssues, customMarkdownFields]);

return (
<Detail
navigationTitle={issue?.key}
markdown={markdown}
isLoading={isLoading}
isLoading={isLoading || isLoadingEpicIssues}
metadata={
<Detail.Metadata>
{hasAttachments ? (
Expand Down Expand Up @@ -171,7 +188,16 @@ export default function IssueDetail({ initialIssue, issueKey }: IssueDetailProps
</Detail.Metadata>
}
{...(issue
? { actions: <IssueActions issue={issue} showAttachmentsAction={hasAttachments} mutateDetail={mutate} /> }
? {
actions: (
<IssueActions
issue={issue}
showChildIssuesAction={hasChildIssues}
showAttachmentsAction={hasAttachments}
mutateDetail={mutate}
/>
),
}
: null)}
/>
);
Expand Down
9 changes: 7 additions & 2 deletions extensions/jira/src/components/IssueListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { format } from "date-fns";
import { Issue } from "../api/issues";
import { getUserAvatar } from "../helpers/avatars";
import { getStatusColor } from "../helpers/issues";
import { useEpicIssues } from "../hooks/useIssues";

import IssueActions from "./IssueActions";

Expand All @@ -16,7 +17,9 @@ type IssueListItemProps = {
export default function IssueListItem({ issue, mutate }: IssueListItemProps) {
const updatedAt = new Date(issue.fields.updated);
const assignee = issue.fields.assignee;

const { issues: epicIssues } = useEpicIssues(issue?.id ?? "");
const hasChildIssues =
(issue.fields.subtasks && issue.fields.subtasks.length > 0) || (epicIssues && epicIssues.length > 0);
const keywords = [issue.key, issue.fields.status.name, issue.fields.issuetype.name];

if (issue.fields.priority) {
Expand Down Expand Up @@ -55,7 +58,9 @@ export default function IssueListItem({ issue, mutate }: IssueListItemProps) {
title={issue.fields.summary || "Unknown issue title"}
subtitle={issue.key}
accessories={accessories}
actions={<IssueActions issue={issue} mutate={mutate} showDetailsAction={true} />}
actions={
<IssueActions issue={issue} showChildIssuesAction={hasChildIssues} mutate={mutate} showDetailsAction={true} />
}
/>
);
}
5 changes: 5 additions & 0 deletions extensions/jira/src/hooks/useIssues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { useCachedPromise } from "@raycast/utils";

import { getIssues } from "../api/issues";

export function useEpicIssues(epicKey: string, options?: Record<string, unknown>) {
const jql = epicKey ? `parent = ${epicKey}` : "issue = null"; // Provide valid JQL when no epic key
const { data: issues, isLoading, mutate } = useCachedPromise((jql) => getIssues({ jql }), [jql], options);
return { issues, isLoading, mutate };
}
export default function useIssues(jql: string, options?: Record<string, unknown>) {
const { data: issues, isLoading, mutate } = useCachedPromise((jql) => getIssues({ jql }), [jql], options);
return { issues, isLoading, mutate };
Expand Down
Loading