From 5b2c6c4eefdc249a5ab1651f7b49e84069779699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= <78490564+maximilianruesch@users.noreply.github.com> Date: Thu, 7 Dec 2023 12:27:25 +0100 Subject: [PATCH 1/4] Remove unused entries from package json and correct (#86) --- package.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/package.json b/package.json index fb57fd91..732c34e0 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,8 @@ "lint": "eslint ./src", "test": "jest", "typecheck": "tsc --noEmit", - "preview": "vite preview", "pree2e": "yarn run package", "e2e": "playwright test", - "canvas": "yarn --cwd project-canvas start", "prepare": "husky install", "pre-commit": "lint-staged" }, @@ -101,6 +99,6 @@ }, "repository": { "type": "git", - "url": "https://github.com/TU-TeamCanvas/ProjectCanvas" + "url": "https://github.com/MaibornWolff/ProjectCanvas" } } From 0f491632d2cf7a31bf79644409feb4e714613203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= <78490564+maximilianruesch@users.noreply.github.com> Date: Thu, 7 Dec 2023 17:49:26 +0100 Subject: [PATCH 2/4] Differentiate between cancel and confirm alert for delete issue popover (#87) --- .../BacklogView/Issue/DeleteButton.tsx | 3 ++- .../Components/DeleteIssue/DeleteIssue.tsx | 23 ++++++++++++++++--- .../DeleteIssue/DeleteIssueAlert.tsx | 10 ++++---- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/components/BacklogView/Issue/DeleteButton.tsx b/src/components/BacklogView/Issue/DeleteButton.tsx index 982070ed..a1d2e2e9 100644 --- a/src/components/BacklogView/Issue/DeleteButton.tsx +++ b/src/components/BacklogView/Issue/DeleteButton.tsx @@ -68,7 +68,8 @@ export function DeleteButton({ }} /> setIssuePopoverOpened(false)} + cancelAlert={() => setIssuePopoverOpened(false)} + confirmAlert={() => setIssuePopoverOpened(false)} /> diff --git a/src/components/DetailView/Components/DeleteIssue/DeleteIssue.tsx b/src/components/DetailView/Components/DeleteIssue/DeleteIssue.tsx index dda7a57e..896afec7 100644 --- a/src/components/DetailView/Components/DeleteIssue/DeleteIssue.tsx +++ b/src/components/DetailView/Components/DeleteIssue/DeleteIssue.tsx @@ -1,5 +1,6 @@ import { Button, Popover } from "@mantine/core" import { IconTrash } from "@tabler/icons" +import {useState} from "react"; import { DeleteIssueAlert } from "./DeleteIssueAlert" export function DeleteIssue({ @@ -9,10 +10,22 @@ export function DeleteIssue({ issueKey: string closeModal: () => void }) { + const [issuePopoverOpened, setIssuePopoverOpened] = useState(false) + return ( - + - @@ -22,7 +35,11 @@ export function DeleteIssue({ theme.colorScheme === "dark" ? theme.colors.dark[7] : theme.white, })} > - + setIssuePopoverOpened(false)} + confirmAlert={closeModal} + /> ) diff --git a/src/components/DetailView/Components/DeleteIssue/DeleteIssueAlert.tsx b/src/components/DetailView/Components/DeleteIssue/DeleteIssueAlert.tsx index c366b340..d924bcd8 100644 --- a/src/components/DetailView/Components/DeleteIssue/DeleteIssueAlert.tsx +++ b/src/components/DetailView/Components/DeleteIssue/DeleteIssueAlert.tsx @@ -5,16 +5,18 @@ import { deleteIssueMutation } from "./queries" export function DeleteIssueAlert({ issueKey, - closeModal, + cancelAlert, + confirmAlert, }: { issueKey: string - closeModal: () => void + cancelAlert: () => void + confirmAlert: () => void }) { const queryClient = useQueryClient() const deleteIssue = deleteIssueMutation(queryClient) return ( - + } title="Attention!" @@ -26,7 +28,7 @@ export function DeleteIssueAlert({ onClick={(e) => { e.stopPropagation() deleteIssue.mutate(issueKey) - closeModal() + confirmAlert() }} > Confirm From 0a53975ea44e32260e8b40728dc66b596c94ac90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= <78490564+maximilianruesch@users.noreply.github.com> Date: Sun, 10 Dec 2023 19:39:09 +0100 Subject: [PATCH 3/4] Allow to reconfigure server credentials via ENV variables (#88) --- .env.example | 6 ++++-- src/components/Login/Login.test.tsx | 3 +++ src/components/Login/jira-server/LoginForm.tsx | 8 +++++--- src/get-meta-env.ts | 5 +++++ 4 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 src/get-meta-env.ts diff --git a/.env.example b/.env.example index dc34a18b..df3dbb28 100644 --- a/.env.example +++ b/.env.example @@ -3,5 +3,7 @@ VITE_CLIENT_ID= VITE_CLIENT_SECRET= VITE_REDIRECT_URI= - - +# JIRA SERVER CREDENTIALS +VITE_JIRA_SERVER_DEFAULT_URL= +VITE_JIRA_SERVER_DEFAULT_USERNAME= +VITE_JIRA_SERVER_DEFAULT_PASSWORD= diff --git a/src/components/Login/Login.test.tsx b/src/components/Login/Login.test.tsx index ada7c116..855f5b85 100644 --- a/src/components/Login/Login.test.tsx +++ b/src/components/Login/Login.test.tsx @@ -3,6 +3,9 @@ import { render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { Login } from "./Login" +jest.mock("../../get-meta-env.ts", () => ({ + getImportMetaEnv: jest.fn(() => ({})), +})) jest.mock("./jira-cloud/loginToJiraCloud.ts", () => ({ loginToJiraCloud: jest.fn(), })) diff --git a/src/components/Login/jira-server/LoginForm.tsx b/src/components/Login/jira-server/LoginForm.tsx index 398e549a..a3aa6ae6 100644 --- a/src/components/Login/jira-server/LoginForm.tsx +++ b/src/components/Login/jira-server/LoginForm.tsx @@ -3,6 +3,7 @@ import { useForm } from "@mantine/form" import { useTranslation } from "react-i18next" import { LoginFormValues } from "./LoginFormValues" import { loginToJiraServer } from "./loginToJiraServer" +import { getImportMetaEnv } from "../../../get-meta-env"; export function LoginForm({ goBack, @@ -12,11 +13,12 @@ export function LoginForm({ onSuccess: () => void }) { const { t } = useTranslation("login") + const metaEnv = getImportMetaEnv() const form = useForm({ initialValues: { - url: "http://localhost:8080", - username: "admin", - password: "admin", + url: metaEnv.VITE_JIRA_SERVER_DEFAULT_URL ?? '', + username: metaEnv.VITE_JIRA_SERVER_DEFAULT_USERNAME ?? '', + password: metaEnv.VITE_JIRA_SERVER_DEFAULT_PASSWORD ?? '', }, }) return ( diff --git a/src/get-meta-env.ts b/src/get-meta-env.ts new file mode 100644 index 00000000..2dd36c93 --- /dev/null +++ b/src/get-meta-env.ts @@ -0,0 +1,5 @@ +export function getImportMetaEnv() { + // Import meta has its own function to allow testing components using the import meta, as it may not be available in + // test environments and should be mocked anyway. + return import.meta.env +} \ No newline at end of file From 8aeffefb192075312f4d8bf5ae2f3f0ccbce3183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= <78490564+maximilianruesch@users.noreply.github.com> Date: Wed, 20 Dec 2023 10:44:01 +0100 Subject: [PATCH 4/4] Add progress bar and child issues to epic detail view (#81) Co-authored-by: Benedict Teutsch Co-authored-by: Julian Rupprecht Co-authored-by: ValriRod <32778515+ValsiRod@users.noreply.github.com> --- .../jira-cloud-provider/JiraCloudProvider.ts | 38 ++- .../JiraServerProvider.ts | 16 +- .../BacklogView/Issue/IssueCard.tsx | 4 +- .../BacklogView/helpers/backlogHelpers.ts | 2 +- .../BacklogView/helpers/queryFetchers.ts | 5 + .../CreateIssue/CreateIssueModal.tsx | 1 + .../CreateIssue/Fields/EpicSelect.tsx | 2 +- .../CreateIssue/Fields/IssueTypeSelect.tsx | 2 +- .../Components/EditableEpic/EditableEpic.tsx | 22 +- .../Components/EditableEpic/queries.ts | 23 -- .../Components/ChildIssue/ChildIssueCard.tsx | 212 +++++++++++++ .../ChildIssue/ChildIssueWrapper.tsx | 13 + .../Components/ChildIssue/ChildIssues.tsx | 66 ++++ .../Components/StoryPointsHoverCard.tsx | 40 +++ .../EpicDetailView/EpicDetailView.tsx | 296 +++++++++++++----- .../helpers/storyPointsHelper.ts | 13 + src/components/EpicView/EpicCard.tsx | 5 - types/index.ts | 5 +- types/jira.ts | 28 +- 19 files changed, 635 insertions(+), 158 deletions(-) delete mode 100644 src/components/DetailView/Components/EditableEpic/queries.ts create mode 100644 src/components/EpicDetailView/Components/ChildIssue/ChildIssueCard.tsx create mode 100644 src/components/EpicDetailView/Components/ChildIssue/ChildIssueWrapper.tsx create mode 100644 src/components/EpicDetailView/Components/ChildIssue/ChildIssues.tsx create mode 100644 src/components/EpicDetailView/Components/StoryPointsHoverCard.tsx create mode 100644 src/components/EpicDetailView/helpers/storyPointsHelper.ts diff --git a/electron/providers/jira-cloud-provider/JiraCloudProvider.ts b/electron/providers/jira-cloud-provider/JiraCloudProvider.ts index e837ec4a..a4c19888 100644 --- a/electron/providers/jira-cloud-provider/JiraCloudProvider.ts +++ b/electron/providers/jira-cloud-provider/JiraCloudProvider.ts @@ -409,9 +409,9 @@ export class JiraCloudProvider implements IProvider { async getIssuesByProject(project: string): Promise { return new Promise((resolve, reject) => { this.getRestApiClient(3) - .get(`/search?jql=project=${project}&maxResults=10000`) + .get(`/search?jql=project=${project}&maxResults=10000&fields=*all`) .then(async (response) => { - resolve(this.fetchIssues(response)) + resolve(this.fetchIssues(response, false)) }) .catch((error) => { reject(new Error(`Error fetching issues by project: ${this.handleFetchIssuesError(error)}`)) @@ -424,7 +424,7 @@ export class JiraCloudProvider implements IProvider { this.getAgileRestApiClient('1.0') .get(`/sprint/${sprintId}/issue`) .then(async (response) => { - resolve(this.fetchIssues(response)) + resolve(this.fetchIssues(response, true)) }) .catch((error) => { reject(new Error(`Error fetching issues by sprint: ${this.handleFetchIssuesError(error)}`)) @@ -440,7 +440,7 @@ export class JiraCloudProvider implements IProvider { this.getAgileRestApiClient('1.0') .get(`/board/${boardId}/backlog?jql=project=${project}&maxResults=500`) .then(async (response) => { - resolve(this.fetchIssues(response)) + resolve(this.fetchIssues(response, true)) }) .catch((error) => { reject(new Error(`Error fetching issues by project: ${this.handleFetchIssuesError(error)}`)) @@ -448,7 +448,7 @@ export class JiraCloudProvider implements IProvider { }) } - async fetchIssues(response: AxiosResponse): Promise { + async fetchIssues(response: AxiosResponse, isAgile: boolean): Promise { const rankCustomField = this.customFields.get("Rank") || "" return new Promise((resolve) => { const issues: Promise = Promise.all( @@ -459,18 +459,30 @@ export class JiraCloudProvider implements IProvider { status: element.fields.status.name, type: element.fields.issuetype.name, storyPointsEstimate: await this.getIssueStoryPointsEstimate(element.key), - epic: element.fields.parent?.fields.summary, + epic: { + issueKey: element.fields.parent?.key, + summary: element.fields.parent?.fields.summary, + }, labels: element.fields.labels, assignee: { displayName: element.fields.assignee?.displayName, avatarUrls: element.fields.assignee?.avatarUrls, }, rank: element.fields[rankCustomField], - description: element.fields.description, + // IMPROVE: Remove boolean flag + description: (isAgile ? element.fields.description : element.fields.description?.content), subtasks: element.fields.subtasks, created: element.fields.created, updated: element.fields.updated, - comment: element.fields.comment, + comment: { + comments: element.fields.comment.comments.map((commentElement) => ({ + id: commentElement.id, + body: (isAgile ? commentElement.body : commentElement.body[0]?.content[0]?.text), + author: commentElement.author, + created: commentElement.created, + updated: commentElement.updated, + })), + }, projectId: element.fields.project.id, sprint: element.fields.sprint, attachments: element.fields.attachment, @@ -658,7 +670,7 @@ export class JiraCloudProvider implements IProvider { { fields: { summary, - parent: { key: epic }, + parent: { key: epic.issueKey }, issuetype: { id: type }, project: { id: projectId, @@ -756,8 +768,8 @@ export class JiraCloudProvider implements IProvider { ...(summary && { summary, }), - ...(epic && { - parent: { key: epic }, + ...(epic && epic.issueKey && { + parent: { key: epic.issueKey }, }), ...(type && { issuetype: { id: type }, @@ -820,7 +832,7 @@ export class JiraCloudProvider implements IProvider { } } - reject(new Error(`Error creating issue: ${specificError}`)) + reject(new Error(`Error editing issue: ${specificError}`)) }) }) } @@ -853,7 +865,9 @@ export class JiraCloudProvider implements IProvider { response.data.issues.map(async (element: JiraIssue) => ({ issueKey: element.key, summary: element.fields.summary, + epic: element.fields.epic, labels: element.fields.labels, + description: element.fields.description.content[0]?.content[0]?.text, assignee: { displayName: element.fields.assignee?.displayName, avatarUrls: element.fields.assignee?.avatarUrls, diff --git a/electron/providers/jira-server-provider/JiraServerProvider.ts b/electron/providers/jira-server-provider/JiraServerProvider.ts index 402c4668..e0467ed9 100644 --- a/electron/providers/jira-server-provider/JiraServerProvider.ts +++ b/electron/providers/jira-server-provider/JiraServerProvider.ts @@ -294,7 +294,7 @@ export class JiraServerProvider implements IProvider { async getIssuesByProject(project: string, boardId: number): Promise { return new Promise((resolve, reject) => { this.getAgileRestApiClient('1.0') - .get(`/board/${boardId}/issue?jql=project=${project}&maxResults=10000`) + .get(`/board/${boardId}/issue?jql=project=${project}&maxResults=10000&fields=*all`) .then((response) => resolve(this.fetchIssues(response))) .catch((error) => reject(new Error(`Error in fetching issues: ${error}`))) }) @@ -336,7 +336,10 @@ export class JiraServerProvider implements IProvider { status: element.fields.status.name, type: element.fields.issuetype.name, storyPointsEstimate: await this.getIssueStoryPointsEstimate(element.key), - epic: element.fields.parent?.fields.summary, + epic: { + issueKey: element.fields.parent?.key, + summary: element.fields.parent?.fields.summary, + }, labels: element.fields.labels, assignee: { displayName: element.fields.assignee?.displayName, @@ -515,7 +518,7 @@ export class JiraServerProvider implements IProvider { { fields: { summary, - parent: { key: epic }, + parent: { key: epic.issueKey }, issuetype: { id: type }, project: { id: projectId, @@ -583,6 +586,9 @@ export class JiraServerProvider implements IProvider { issueKey: element.key, summary: element.fields.summary, labels: element.fields.labels, + created: element.fields.created, + updated: element.fields.updated, + description: element.fields.description.content, assignee: { displayName: element.fields.assignee?.displayName, avatarUrls: element.fields.assignee?.avatarUrls, @@ -854,8 +860,8 @@ export class JiraServerProvider implements IProvider { ...(summary && { summary, }), - ...(epic && { - parent: { key: epic }, + ...(epic && epic.issueKey && { + parent: { key: epic.issueKey }, }), ...(type && { issuetype: { id: type }, diff --git a/src/components/BacklogView/Issue/IssueCard.tsx b/src/components/BacklogView/Issue/IssueCard.tsx index 067397b4..f4333642 100644 --- a/src/components/BacklogView/Issue/IssueCard.tsx +++ b/src/components/BacklogView/Issue/IssueCard.tsx @@ -117,9 +117,9 @@ export function IssueCard({ > {issueKey} - {epic && ( + {epic.issueKey && ( - {epic} + {epic.summary} )} {labels?.length !== 0 && diff --git a/src/components/BacklogView/helpers/backlogHelpers.ts b/src/components/BacklogView/helpers/backlogHelpers.ts index 37a2aebc..d2cb3bcf 100644 --- a/src/components/BacklogView/helpers/backlogHelpers.ts +++ b/src/components/BacklogView/helpers/backlogHelpers.ts @@ -64,7 +64,7 @@ export const searchIssuesFilter = ( .issues.filter( (issue: Issue) => issue.summary.toLowerCase().includes(currentSearch.toLowerCase()) || - issue.epic?.toLowerCase().includes(currentSearch.toLowerCase()) || + issue.epic.summary?.toLowerCase().includes(currentSearch.toLowerCase()) || issue.assignee?.displayName ?.toLowerCase() .includes(currentSearch.toLowerCase()) || diff --git a/src/components/BacklogView/helpers/queryFetchers.ts b/src/components/BacklogView/helpers/queryFetchers.ts index c5491b26..122247f9 100644 --- a/src/components/BacklogView/helpers/queryFetchers.ts +++ b/src/components/BacklogView/helpers/queryFetchers.ts @@ -3,6 +3,11 @@ import { Issue, Sprint, SprintCreate } from "types" export const getSprints = (boardId: number): Promise => window.provider.getSprints(boardId) +export const getIssuesByProject = ( + projectKey: string | undefined, + boardId: number +): Promise => window.provider.getIssuesByProject(projectKey || "", boardId) + export const getIssuesBySprint = ( sprintId: number | undefined ): Promise => window.provider.getIssuesBySprint(sprintId || 0) diff --git a/src/components/CreateIssue/CreateIssueModal.tsx b/src/components/CreateIssue/CreateIssueModal.tsx index 2103d7cd..204748ab 100644 --- a/src/components/CreateIssue/CreateIssueModal.tsx +++ b/src/components/CreateIssue/CreateIssueModal.tsx @@ -72,6 +72,7 @@ export function CreateIssueModal({ status: "To Do", reporter: currentUser, priority: { id: "" }, + epic: { issueKey: undefined } } as Issue, }) diff --git a/src/components/CreateIssue/Fields/EpicSelect.tsx b/src/components/CreateIssue/Fields/EpicSelect.tsx index a461edb2..196149e3 100644 --- a/src/components/CreateIssue/Fields/EpicSelect.tsx +++ b/src/components/CreateIssue/Fields/EpicSelect.tsx @@ -68,7 +68,7 @@ export function EpicSelect({ searchable clearable withinPortal - {...form.getInputProps("epic")} + {...form.getInputProps("epic.issueKey")} /> diff --git a/src/components/CreateIssue/Fields/IssueTypeSelect.tsx b/src/components/CreateIssue/Fields/IssueTypeSelect.tsx index 93189cd1..190a78fa 100644 --- a/src/components/CreateIssue/Fields/IssueTypeSelect.tsx +++ b/src/components/CreateIssue/Fields/IssueTypeSelect.tsx @@ -38,7 +38,7 @@ export function IssueTypeSelect({ ) { form.setFieldValue("sprintId", null as unknown as string) form.setFieldValue("storyPointsEstimate", null as unknown as number) - form.setFieldValue("epic", null as unknown as string) + form.setFieldValue("epic.issueKey", undefined) } form.setFieldValue("status", "To Do") form.setFieldValue("priority.id", null) diff --git a/src/components/DetailView/Components/EditableEpic/EditableEpic.tsx b/src/components/DetailView/Components/EditableEpic/EditableEpic.tsx index e56a594a..a688605b 100644 --- a/src/components/DetailView/Components/EditableEpic/EditableEpic.tsx +++ b/src/components/DetailView/Components/EditableEpic/EditableEpic.tsx @@ -15,20 +15,22 @@ export function EditableEpic({ }: { projectId: string issueKey: string - epic: string + epic: { + issueKey?: string, + summary?: string, + } }) { - const [showEpicInput, setshowEpicInput] = useState(false) + const [showEpicInput, setShowEpicInput] = useState(false) const { data: epics } = useQuery({ queryKey: ["epics", projectId], queryFn: () => getEpicsByProject(projectId), }) - const [selectedEpic, setselectedEpic] = useState(epic) + const [selectedEpic, setSelectedEpic] = useState(epic.issueKey) const mutationEpic = useMutation({ - mutationFn: (epicKey: string) => - editIssue({ epic: epicKey } as Issue, issueKey), + mutationFn: (epicKey: string) => editIssue({ epic: { issueKey: epicKey } } as Issue, issueKey), onError: () => { showNotification({ - message: `An error occured while modifing the Epic 😢`, + message: `An error occurred while modifing the Epic 😢`, color: "red", }) }, @@ -49,7 +51,7 @@ export function EditableEpic({ placeholder="" nothingFound="No Options" data={ - epics && epics instanceof Array + epics ? epics.map((epicItem) => ({ value: epicItem.issueKey, label: epicItem.summary, @@ -61,16 +63,16 @@ export function EditableEpic({ itemComponent={SelectItem} value={selectedEpic} onChange={(value) => { - setselectedEpic(value!) + setSelectedEpic(value!) mutationEpic.mutate(value!) - setshowEpicInput(false) + setShowEpicInput(false) }} w="300px" /> ) : ( setshowEpicInput(true)} + onClick={() => setShowEpicInput(true)} sx={{ ":hover": { textDecoration: "underline", cursor: "pointer" }, }} diff --git a/src/components/DetailView/Components/EditableEpic/queries.ts b/src/components/DetailView/Components/EditableEpic/queries.ts deleted file mode 100644 index 209f16ba..00000000 --- a/src/components/DetailView/Components/EditableEpic/queries.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { showNotification } from "@mantine/notifications" -import { QueryClient, useMutation } from "@tanstack/react-query" -import { Issue } from "types" -import { editIssue } from "../../helpers/queryFunctions" - -export const editIssueMutation = (queryClient: QueryClient, issueKey: string) => - useMutation({ - mutationFn: (epicKey: string) => - editIssue({ epic: epicKey } as Issue, issueKey), - onError: () => { - showNotification({ - message: `The epic couldn't be modified! 😢`, - color: "red", - }) - }, - onSuccess: () => { - showNotification({ - message: `The epic for issue ${issueKey} has been modified!`, - color: "green", - }) - queryClient.invalidateQueries({ queryKey: ["issues"] }) - }, - }) diff --git a/src/components/EpicDetailView/Components/ChildIssue/ChildIssueCard.tsx b/src/components/EpicDetailView/Components/ChildIssue/ChildIssueCard.tsx new file mode 100644 index 00000000..2b5b8cf3 --- /dev/null +++ b/src/components/EpicDetailView/Components/ChildIssue/ChildIssueCard.tsx @@ -0,0 +1,212 @@ +import { + Avatar, + Badge, + Center, + Box, + Group, + Modal, + Paper, + Stack, + Text, + Tooltip, + useMantineTheme, + Grid, +} from "@mantine/core" +import { useHover } from "@mantine/hooks" +import { useQueryClient } from "@tanstack/react-query" +import { useState } from "react" +import { Issue } from "../../../../../types" +import { DetailView } from "../../../DetailView/DetailView" +import { IssueIcon } from "../../../BacklogView/Issue/IssueIcon" +import { DeleteButton } from "../../../BacklogView/Issue/DeleteButton" + +export function ChildIssueCard({ + issueKey, + summary, + status, + type, + storyPointsEstimate, + epic, + labels, + assignee, + index, + projectId, + ...props +}: Issue & { index: number }) { + let storyPointsColor: string + const [opened, setOpened] = useState(false) + const queryClient = useQueryClient() + const { hovered } = useHover() + const theme = useMantineTheme() + + const hoverStyles = + theme.colorScheme === "dark" + ? { + backgroundColor: theme.colors.dark[8], + transition: "background-color .1s ease-in", + } + : { + backgroundColor: theme.colors.gray[1], + transition: "background-color .1s ease-in", + } + + switch (status) { + case "To Do": + storyPointsColor = "gray.6" + break + case "In Progress": + storyPointsColor = "blue.8" + break + case "Done": + storyPointsColor = "green.9" + break + default: + storyPointsColor = "gray.6" + } + + return ( + <> + setOpened(true)} sx={{ position: "relative" }}> + + + +
+ +
+
+ + + + + {issueKey} + + {epic.issueKey && ( + + {epic.summary} + + )} + {labels?.length !== 0 && + labels.map((label) => ( + + {label} + + ))} + + {summary} + + {type} + • + {status} + + + + + + + {storyPointsEstimate} + + + + + + {assignee?.avatarUrls !== undefined ? ( + + ) : ( + + )} + + +
+
+ { + setOpened(false) + queryClient.invalidateQueries({ queryKey: ["issues"] }) + }} + size="90vw" + overflow="outside" + overlayOpacity={0.55} + overlayBlur={3} + withCloseButton={false} + > + setOpened(false)} + {...props} + /> + + + ) +} diff --git a/src/components/EpicDetailView/Components/ChildIssue/ChildIssueWrapper.tsx b/src/components/EpicDetailView/Components/ChildIssue/ChildIssueWrapper.tsx new file mode 100644 index 00000000..38234a30 --- /dev/null +++ b/src/components/EpicDetailView/Components/ChildIssue/ChildIssueWrapper.tsx @@ -0,0 +1,13 @@ +import { Stack } from "@mantine/core" +import { Issue } from "../../../../../types" +import { ChildIssueCard } from "./ChildIssueCard" + +export function ChildIssueWrapper({ issues }: { issues: Issue[] }) { + return ( + + {issues.map((issue: Issue, index) => ( + + ))} + + ) +} diff --git a/src/components/EpicDetailView/Components/ChildIssue/ChildIssues.tsx b/src/components/EpicDetailView/Components/ChildIssue/ChildIssues.tsx new file mode 100644 index 00000000..25c5bfb8 --- /dev/null +++ b/src/components/EpicDetailView/Components/ChildIssue/ChildIssues.tsx @@ -0,0 +1,66 @@ +import { Box, Button, Divider, ScrollArea, Stack } from "@mantine/core" +import { useState } from "react" +import { Issue } from "../../../../../types" +import { CreateIssueModal } from "../../../CreateIssue/CreateIssueModal" +import { ChildIssueWrapper } from "./ChildIssueWrapper" + +export function ChildIssues({ issues }: { issues: Issue[] }) { + const [createIssueModalOpened, setCreateIssueModalOpened] = useState(false) + + return ( + + + + + + + + + + + + + ) +} diff --git a/src/components/EpicDetailView/Components/StoryPointsHoverCard.tsx b/src/components/EpicDetailView/Components/StoryPointsHoverCard.tsx new file mode 100644 index 00000000..8b45c143 --- /dev/null +++ b/src/components/EpicDetailView/Components/StoryPointsHoverCard.tsx @@ -0,0 +1,40 @@ +import {Text, HoverCard, Badge} from "@mantine/core" +import {MantineColor} from "@mantine/styles"; + +export function StoryPointsHoverCard({ + statusType, + color, + count = 0 + }: { + statusType: string + color: MantineColor, + count: number +}) { + return ( + + + + {count} + + + + + Total story points for {statusType} issues: {count} + + + + ) +} diff --git a/src/components/EpicDetailView/EpicDetailView.tsx b/src/components/EpicDetailView/EpicDetailView.tsx index 3e9ef189..921689be 100644 --- a/src/components/EpicDetailView/EpicDetailView.tsx +++ b/src/components/EpicDetailView/EpicDetailView.tsx @@ -2,15 +2,19 @@ import { Accordion, Box, Breadcrumbs, + Center, Group, + Loader, Paper, + Progress, ScrollArea, Stack, Text, Title, } from "@mantine/core" import { Issue, User } from "types" -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useEffect, useState } from "react" import { AssigneeMenu } from "../DetailView/Components/AssigneeMenu" import { Description } from "../DetailView/Components/Description" import { IssueSummary } from "./Components/IssueSummary" @@ -19,17 +23,27 @@ import { ReporterMenu } from "../DetailView/Components/ReporterMenu" import { DeleteIssue } from "../DetailView/Components/DeleteIssue" import { ColorSchemeToggle } from "../common/ColorSchemeToggle" import { IssueIcon } from "../BacklogView/Issue/IssueIcon" +import { ChildIssues } from "./Components/ChildIssue/ChildIssues" +import { getIssuesByProject } from "../BacklogView/helpers/queryFetchers" +import { sortIssuesByRank } from "../BacklogView/helpers/backlogHelpers" +import { useCanvasStore } from "../../lib/Store" +import { resizeDivider } from "../BacklogView/helpers/resizeDivider" +import { + inProgressAccumulator, + storyPointsAccumulator, +} from "./helpers/storyPointsHelper" +import { StoryPointsHoverCard } from "./Components/StoryPointsHoverCard"; export function EpicDetailView({ - issueKey, - summary, - labels, - assignee, - description, - created, - updated, - closeModal, - }: { + issueKey, + summary, + labels, + assignee, + description, + created, + updated, + closeModal, +}: { issueKey: string summary: string labels: string[] @@ -40,91 +54,199 @@ export function EpicDetailView({ closeModal: () => void }) { const queryClient = useQueryClient() - const reloadEpics = () => queryClient.invalidateQueries({ queryKey: ["epics"] }); + const reloadEpics = () => queryClient.invalidateQueries({ queryKey: ["epics"] }) const dateFormat = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "short", - }); + }) + + const projectKey = useCanvasStore((state) => state.selectedProject?.key) + const boardIds = useCanvasStore((state) => state.selectedProjectBoardIds) + const currentBoardId = boardIds[0] + + const [childIssues, setChildIssues] = useState([]) + + const { isLoading: isLoadingChildIssues } = useQuery({ + queryKey: ["issues", projectKey, currentBoardId, issueKey], + queryFn: () => getIssuesByProject(projectKey, currentBoardId), + enabled: !!projectKey, + onSuccess: (newChildIssues) => { + setChildIssues( + newChildIssues + ?.filter((issue: Issue) => issue.epic.issueKey === issueKey) + ?.sort((issueA: Issue, issueB: Issue) => sortIssuesByRank(issueA, issueB)) + ?? [], + ) + }, + }) + + const tasksOpen = inProgressAccumulator(childIssues, "To Do") + const tasksInProgress = inProgressAccumulator(childIssues, "In Progress") + const tasksDone = inProgressAccumulator(childIssues, "Done") + + useEffect(() => { + resizeDivider() + }, [isLoadingChildIssues]) + + if (isLoadingChildIssues) + return ( +
+ +
+ ) return ( - - - - {issueKey} - - - + + + {issueKey} + + + + + + + <IssueSummary + summary={summary} + issueKey={issueKey} + onMutate={reloadEpics} + /> + + + Description + + - - - - <IssueSummary summary={summary} issueKey={issueKey} onMutate={reloadEpics} /> - - - Description - - - - - - - - - - - - - - - Details - - - - + + + + + + + + + + + + + + + + + + + + + Details + + + + + + + Labels + + - - - Labels - - - - - - - - - - Created{" "} - {dateFormat.format(new Date(created))} - - - Updated{" "} - {dateFormat.format(new Date(updated))} - - - - - +
+ +
+ + + + + Created {dateFormat.format(new Date(created))} + + + Updated {dateFormat.format(new Date(updated))} + + + + + ) -} \ No newline at end of file +} diff --git a/src/components/EpicDetailView/helpers/storyPointsHelper.ts b/src/components/EpicDetailView/helpers/storyPointsHelper.ts new file mode 100644 index 00000000..51a77340 --- /dev/null +++ b/src/components/EpicDetailView/helpers/storyPointsHelper.ts @@ -0,0 +1,13 @@ +import { Issue } from "../../../../types" + +export const storyPointsAccumulator = (issues: Issue[], status: string) => + issues.reduce( + (accumulator, currentValue) => accumulator + (currentValue.status === status ? currentValue.storyPointsEstimate ?? 0 : 0) ?? 0, + 0, + ) + +export const inProgressAccumulator = (issues: Issue[], status: string) => + issues.reduce( + (accumulator, currentValue) => accumulator + (currentValue.status === status ? 1 : 0), + 0, + ) diff --git a/src/components/EpicView/EpicCard.tsx b/src/components/EpicView/EpicCard.tsx index 2346a8bf..9bd003e5 100644 --- a/src/components/EpicView/EpicCard.tsx +++ b/src/components/EpicView/EpicCard.tsx @@ -114,11 +114,6 @@ export function EpicCard ({ > {issueKey} - {epic && ( - - {epic} - - )} {labels?.length !== 0 && labels.map((label) => (