From ce7b86efe286a4cdb5932e78b63f1af62b66ee29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20R=C3=BCsch?= <78490564+maximilianruesch@users.noreply.github.com> Date: Tue, 16 Jan 2024 23:52:51 +0100 Subject: [PATCH] Support export of issues in backlog view (#115) Co-authored-by: ValriRod <32778515+ValsiRod@users.noreply.github.com> Co-authored-by: Julian Rupprecht Co-authored-by: Benedict Teutsch Co-authored-by: ayman Co-authored-by: ayman <118556179+aymka@users.noreply.github.com> --- .github/workflows/build.yaml | 2 +- electron/export-issues.ts | 34 ++++ electron/main.ts | 4 + package.json | 5 +- src/components/BacklogView/BacklogView.tsx | 26 ++- src/components/CreateExport/CheckboxStack.tsx | 59 +++++++ .../CreateExport/CreateExportModal.tsx | 156 ++++++++++++++++++ src/components/CreateExport/exportHelper.ts | 60 +++++++ types/index.ts | 7 +- yarn.lock | 7 +- 10 files changed, 350 insertions(+), 10 deletions(-) create mode 100644 electron/export-issues.ts create mode 100644 src/components/CreateExport/CheckboxStack.tsx create mode 100644 src/components/CreateExport/CreateExportModal.tsx create mode 100644 src/components/CreateExport/exportHelper.ts diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 4dfcdd53..ced60f75 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -26,7 +26,7 @@ jobs: cache: "yarn" - name: Install dependencies - run: yarn install --frozen-lockfile + run: yarn install --frozen-lockfile --network-timeout 1000000 - name: Build env: diff --git a/electron/export-issues.ts b/electron/export-issues.ts new file mode 100644 index 00000000..890a4f59 --- /dev/null +++ b/electron/export-issues.ts @@ -0,0 +1,34 @@ +import fs from "fs"; +import * as Electron from "electron"; + +export const enum ExportStatus { + SUCCESS = 'success', + CANCELED = 'canceled', + ERROR = 'error', +} + +export type ExportReply = { status: ExportStatus, error?: string } + +export const getExportIssuesHandler = + (electron: typeof Electron, mainWindow: Electron.BrowserWindow) => + (event: Electron.IpcMainEvent, data: string) => { + electron.dialog.showSaveDialog(mainWindow!, { + title: "Export issues to CSV", + defaultPath: electron.app.getPath("downloads"), + filters: [{ name: 'CSV file', extensions: ['csv'] }], + properties: [] + }) + .then(file=> { + if (file.canceled) { + event.reply("exportIssuesReply", { status: ExportStatus.CANCELED }); + } else { + fs.writeFile( + file.filePath?.toString() ?? electron.app.getPath('downloads'), + data, + (err) => { if(err) throw err; }, + ) + event.reply("exportIssuesReply", { status: ExportStatus.SUCCESS }); + } + }) + .catch((e) => event.reply("exportIssuesReply", { status: ExportStatus.ERROR, error: e.toString })); + } \ No newline at end of file diff --git a/electron/main.ts b/electron/main.ts index 66af91ac..42aace8d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,5 @@ import { ipcMain, shell, app, BrowserWindow } from "electron" +import * as electron from 'electron' import path from "path" import { handleOAuth2 } from "./OAuthHelper" import { @@ -35,6 +36,7 @@ import { refreshAccessToken, setTransition, } from "./provider" +import { getExportIssuesHandler } from "./export-issues"; declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string declare const MAIN_WINDOW_VITE_NAME: string @@ -134,6 +136,8 @@ app.whenReady().then(() => { ipcMain.handle("editIssueComment", editIssueComment) ipcMain.handle("deleteIssueComment", deleteIssueComment) ipcMain.handle("getResource", getResource) + + ipcMain.on("exportIssues", getExportIssuesHandler(electron, mainWindow!)) }) app.whenReady().then(() => { diff --git a/package.json b/package.json index af7842bc..ffea3e02 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@tanstack/react-query": "^4.23.0", "@tanstack/react-query-devtools": "^4.23.0", "@types/file-saver": "^2.0.5", + "@types/lodash": "^4.14.202", "axios": "^1.6.1", "cross-fetch": "^4.0.0", "dayjs": "^1.11.7", @@ -46,6 +47,7 @@ "file-saver": "^2.0.5", "i18next": "21.10.0", "immer": "^9.0.19", + "lodash": "^4.17.21", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "11.18.6", @@ -102,5 +104,6 @@ "repository": { "type": "git", "url": "https://github.com/MaibornWolff/ProjectCanvas" - } + }, + "packageManager": "yarn@1.22.21" } diff --git a/src/components/BacklogView/BacklogView.tsx b/src/components/BacklogView/BacklogView.tsx index 6fbf4eff..15e178eb 100644 --- a/src/components/BacklogView/BacklogView.tsx +++ b/src/components/BacklogView/BacklogView.tsx @@ -20,6 +20,7 @@ import { useNavigate } from "react-router-dom" import { Issue, Sprint } from "types" import { useCanvasStore } from "../../lib/Store" import { CreateIssueModal } from "../CreateIssue/CreateIssueModal" +import { CreateExportModal } from "../CreateExport/CreateExportModal" import { CreateSprint } from "./CreateSprint/CreateSprint" import { searchIssuesFilter, sortIssuesByRank } from "./helpers/backlogHelpers" import { onDragEnd } from "./helpers/draggingHelpers" @@ -32,7 +33,7 @@ import { resizeDivider } from "./helpers/resizeDivider" import { DraggableIssuesWrapper } from "./IssuesWrapper/DraggableIssuesWrapper" import { SprintsPanel } from "./IssuesWrapper/SprintsPanel" import { ReloadButton } from "./ReloadButton" -import { useColorScheme } from "../../common/color-scheme"; +import { useColorScheme } from "../../common/color-scheme" export function BacklogView() { const colorScheme = useColorScheme() @@ -43,6 +44,7 @@ export function BacklogView() { const boardIds = useCanvasStore((state) => state.selectedProjectBoardIds) const currentBoardId = boardIds[0] const [search, setSearch] = useState("") + const [createExportModalOpened, setCreateExportModalOpened] = useState(false) const [issuesWrappers, setIssuesWrappers] = useState( new Map() @@ -110,7 +112,7 @@ export function BacklogView() { ? backlogIssues .filter( (issue: Issue) => - issue.type !== "Epic" && issue.type !== "Subtask" + issue.type !== "Subtask" ) .sort((issueA: Issue, issueB: Issue) => sortIssuesByRank(issueA, issueB) @@ -188,7 +190,16 @@ export function BacklogView() { / {projectName} - + + + Backlog issue.type !== "Epic")} /> )} @@ -239,7 +250,10 @@ export function BacklogView() { style={(theme) => ({ justifyContent: "left", ":hover": { - background: colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[4], + background: + colorScheme === "dark" + ? theme.colors.dark[4] + : theme.colors.gray[4], }, })} > @@ -266,7 +280,7 @@ export function BacklogView() { p="xs" style={{ maxHeight: "calc(100vh - 230px)", - minWidth: "260px" + minWidth: "260px", }} > void +}) { + const theme = createTheme({ cursorType: 'pointer' }); + const [includedValues, setIncludedValues] = useState( + data + .filter((d) => d.active) + .map((d) => d.value) + ); + + const allValues = data.map((d) => d.value) + const allValuesSelected = isEqual(includedValues.sort(), allValues.sort()) + + const changeValues = (newValues: string[]) => { + setIncludedValues(newValues) + onChange(newValues); + } + + function toggleValue(value: string) { + if (includedValues.indexOf(value) < 0) changeValues([...includedValues, value]); + else changeValues(includedValues.filter(containedValue => containedValue !== value)); + } + + return ( + + + 0} + onChange={() => changeValues(allValuesSelected ? [] : allValues)} + /> + + {data && data.map((d) => ( + toggleValue(d.value)} + /> + ))} + + + ) +} diff --git a/src/components/CreateExport/CreateExportModal.tsx b/src/components/CreateExport/CreateExportModal.tsx new file mode 100644 index 00000000..b8ad1baf --- /dev/null +++ b/src/components/CreateExport/CreateExportModal.tsx @@ -0,0 +1,156 @@ +import { Dispatch, SetStateAction, useEffect, useState } from "react" +import { Modal, Stack, Group, Text, Button, Tooltip, Paper, ActionIcon } from "@mantine/core" +import { uniqWith, sortBy } from "lodash"; +import { useQuery } from "@tanstack/react-query"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { useCanvasStore } from "../../lib/Store"; +import { Issue } from "../../../types"; +import { exportIssues } from "./exportHelper"; +import { getIssuesByProject } from "../BacklogView/helpers/queryFetchers"; +import { StatusType } from "../../../types/status"; +import { CheckboxStack } from "./CheckboxStack"; + +export function CreateExportModal({ + opened, + setOpened, +}: { + opened: boolean + setOpened: Dispatch> +}) { + const project = useCanvasStore((state) => state.selectedProject); + const boardId = useCanvasStore((state) => state.selectedProjectBoardIds)[0] + + const { data: issues } = useQuery({ + queryKey: ["issues", project?.key], + queryFn: () => project && getIssuesByProject(project.key, boardId), + enabled: !!project?.key, + initialData: [], + }); + + const { data: issueTypes } = useQuery({ + queryKey: ["issueTypes", project?.key], + queryFn: () => project && window.provider.getIssueTypesByProject(project.key), + enabled: !!project?.key, + initialData: [], + }); + + const allStatus = sortBy( + uniqWith( + issueTypes?.flatMap((issueType) => issueType.statuses ?? []), + (statusA, statusB) => statusA.id === statusB.id, + ), + [ + (status) => Object.values(StatusType).indexOf(status.statusCategory.name as StatusType), + 'name', + ], + ) + + const allStatusNamesByCategory: { [key: string]: string[] } = {}; + allStatus.forEach((status) => { + allStatusNamesByCategory[status.statusCategory.name] ??= []; + allStatusNamesByCategory[status.statusCategory.name].push(status.name); + }); + + const [includedIssueTypes, setIncludedIssueTypes] = useState([]); + const [includedIssueStatus, setIncludedIssueStatus] = useState([]); + const [issuesToExport, setIssuesToExport] = useState([]); + + function calculateIssuesToExport() { + setIssuesToExport( + sortBy( + issues + .filter((issue) => includedIssueTypes.includes(issue.type)) + .filter((issue) => includedIssueStatus.includes(issue.status) + && allStatusNamesByCategory[StatusType.DONE].includes(issue.status)), + ['issueKey'] + ) + ); + } + + useEffect(() => { + calculateIssuesToExport(); + }, [includedIssueTypes, includedIssueStatus]); + + return ( + { + setIncludedIssueTypes([]); + setIncludedIssueStatus([]); + setOpened(false); + }} + centered + withCloseButton={false} + size="40vw" + > + + + {project?.name} + + + + + + + + + + Include Issue Types + {issueTypes && ( + ({ + value: issueType.name!, + label: issueType.name!, + }))} + onChange={(includedTypes) => setIncludedIssueTypes(includedTypes)} + /> + )} + + + Include Issue Status + {allStatus && ( + ({ + value: status.name, + label: status.name, + }))} + onChange={(includedStatus) => setIncludedIssueStatus(includedStatus)} + /> + )} + + + + + + Issues to export: {issuesToExport.length} + + + + + + ) +} diff --git a/src/components/CreateExport/exportHelper.ts b/src/components/CreateExport/exportHelper.ts new file mode 100644 index 00000000..7e324528 --- /dev/null +++ b/src/components/CreateExport/exportHelper.ts @@ -0,0 +1,60 @@ +import {ipcRenderer} from "electron"; +import {showNotification} from "@mantine/notifications"; +import {Issue} from "../../../types"; +import {ExportReply, ExportStatus} from "../../../electron/export-issues"; + +type ExportableIssue = Issue & { startDate: Date, endDate: Date, workingDays: number } + +const addExportedTimeProperties = (issue: Issue): ExportableIssue => ({ + ...issue, + startDate: new Date(), + endDate: new Date(), + workingDays: 0, +}) + + +export const exportIssues = (issues: Issue[]) => { + const header = ["ID", "Name", "Start Date", "End Date", "Working days"] + const data = [header.map((h) => `"${h}"`).join(",")] + + issues.forEach((issue) => { + const exportableIssue = addExportedTimeProperties(issue); + const exportedValues = [ + `"${exportableIssue.issueKey}"`, + `"${exportableIssue.summary}"`, + `"${exportableIssue.startDate?.toISOString()}"`, + `"${exportableIssue.endDate?.toISOString()}"`, + exportableIssue.workingDays, + ] + + data.push(exportedValues.join(',')) + }); + + ipcRenderer.send("exportIssues", data.join("\n")); + ipcRenderer.once( + "exportIssuesReply", + (_, reply: ExportReply) => { + let message; + let color; + switch (reply.status) { + case ExportStatus.ERROR: + message = reply.error + color = "red" + break; + case ExportStatus.CANCELED: + message = "Canceled saving file" + color = "red" + break; + case ExportStatus.SUCCESS: + message = "File saving success" + color = "green" + break; + default: + message = "Unknown" + color = "gray" + } + + showNotification({ message, color }) + }); +} + diff --git a/types/index.ts b/types/index.ts index 553afd2a..c4fd6179 100644 --- a/types/index.ts +++ b/types/index.ts @@ -88,10 +88,15 @@ export interface Issue { attachments: Attachment[] } -interface IssueStatus { +export interface IssueStatus { description?: string id: string name: string + statusCategory: { + id: number, + key: string, + name: string, + } } export interface IssueType { diff --git a/yarn.lock b/yarn.lock index e084f9c2..e3a7cf84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1736,6 +1736,11 @@ dependencies: "@types/node" "*" +"@types/lodash@^4.14.202": + version "4.14.202" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.202.tgz#f09dbd2fb082d507178b2f2a5c7e74bd72ff98f8" + integrity sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ== + "@types/minimatch@*": version "5.1.2" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" @@ -5857,7 +5862,7 @@ lodash.templatesettings@^4.0.0: dependencies: lodash._reinterpolate "^3.0.0" -lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.4: +lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==