Skip to content

Commit

Permalink
Add child issues to Jira extension (#15204)
Browse files Browse the repository at this point in the history
* Update jira extension

- added Child Issues
- Initial commit

* fixed errors related to fetching

* removed contributor

* Update jira extension

- added Child Issues
- removed contributor
- fixed errors related to fetching
- added Child Issues

* fixed error on fetching child issues

* fix mutate usage for epic issues on IssueChildIssues.tsx

* fix mutate usage for epic issues on IssueChildIssues.tsx

* using useIssues instead of local declaration

* Update jira extension

- fix: update UI for non-epic issues in IssueChildIssues
- added child issues to jira extension
- Initial commit

* added missing changelog

* fixed codestyle errors

* Update CHANGELOG.md

* Update package.json

wrong github name was provided

* Update package.json

i guess name it supposed to be the one on the raycast account

* Update jira extension

- Merge branch \'contributions/merge-1731595802691143000\'
- Pull contributions
- removed duplicated hook call

* fixed duplicates

* Cleanup

* Update CHANGELOG.md and optimise images

---------

Co-authored-by: Per Nielsen Tikær <[email protected]>
Co-authored-by: Thomas Lombart <[email protected]>
Co-authored-by: raycastbot <[email protected]>
  • Loading branch information
4 people authored Nov 15, 2024
1 parent 8c7760e commit a00d6ec
Show file tree
Hide file tree
Showing 8 changed files with 120 additions and 11 deletions.
5 changes: 5 additions & 0 deletions extensions/jira/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Jira Changelog

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

- 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.
- Resolved an issue where the child’s status updates were not reflected in the user interface upon changing their status.

## [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
55 changes: 55 additions & 0 deletions extensions/jira/src/components/IssueChildIssues.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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 }) {
// Only create JQL if there are subtasks
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");

const isEpic = issue.fields.issuetype?.name === "Epic";
const {
mutate: mutateEpicIssues,
issues: epicIssues,
isLoading: isLoadingEpicIssues,
} = useEpicIssues(isEpic ? issue.id : "");

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}`,
}));
}, [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";
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

0 comments on commit a00d6ec

Please sign in to comment.