Skip to content

Commit

Permalink
Support export of issues in backlog view (#115)
Browse files Browse the repository at this point in the history
Co-authored-by: ValriRod <32778515+ValsiRod@users.noreply.github.com>
Co-authored-by: Julian Rupprecht <rupprecht.julian@web.de>
Co-authored-by: Benedict Teutsch <bene210@web.de>
Co-authored-by: ayman <ayman.kallouz@gmail.com>
Co-authored-by: ayman <118556179+aymka@users.noreply.github.com>
  • Loading branch information
6 people authored Jan 16, 2024
1 parent 5699856 commit ce7b86e
Show file tree
Hide file tree
Showing 10 changed files with 350 additions and 10 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
34 changes: 34 additions & 0 deletions electron/export-issues.ts
Original file line number Diff line number Diff line change
@@ -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 }));
}
4 changes: 4 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(() => {
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -102,5 +104,6 @@
"repository": {
"type": "git",
"url": "https://github.com/MaibornWolff/ProjectCanvas"
}
},
"packageManager": "yarn@1.22.21"
}
26 changes: 20 additions & 6 deletions src/components/BacklogView/BacklogView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()
Expand All @@ -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<string, { issues: Issue[]; sprint?: Sprint }>()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -188,7 +190,16 @@ export function BacklogView() {
<Text>/</Text>
<Text>{projectName}</Text>
</Group>
<ReloadButton ml="auto" mr="xs" />
<Button ml="auto" size="xs"
onClick={() => setCreateExportModalOpened(true)}
>
Export
</Button>
<CreateExportModal
opened={createExportModalOpened}
setOpened={setCreateExportModalOpened}
/>
<ReloadButton mr="xs" />
</Group>
<Title mb="sm">Backlog</Title>
<TextInput
Expand Down Expand Up @@ -222,7 +233,7 @@ export function BacklogView() {
<Box mr="xs">
<DraggableIssuesWrapper
id="Backlog"
issues={searchedissuesWrappers.get("Backlog")!.issues}
issues={searchedissuesWrappers.get("Backlog")!.issues.filter(issue => issue.type !== "Epic")}
/>
</Box>
)}
Expand All @@ -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],
},
})}
>
Expand All @@ -266,7 +280,7 @@ export function BacklogView() {
p="xs"
style={{
maxHeight: "calc(100vh - 230px)",
minWidth: "260px"
minWidth: "260px",
}}
>
<SprintsPanel
Expand Down
59 changes: 59 additions & 0 deletions src/components/CreateExport/CheckboxStack.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useState } from "react"
import { Divider, Stack, Checkbox, MantineProvider, createTheme } from "@mantine/core"
import { isEqual } from "lodash";

export function CheckboxStack({
data,
onChange,
}: {
data: {
label: string,
value: string,
active?: boolean,
}[],
onChange: (activeValues: string[]) => void
}) {
const theme = createTheme({ cursorType: 'pointer' });
const [includedValues, setIncludedValues] = useState<string[]>(
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 (
<Stack mt="-12%">
<MantineProvider theme={theme}>
<Checkbox
{...(!allValuesSelected && { c: "dimmed" })}
label="Select All"
checked={allValuesSelected}
indeterminate={!allValuesSelected && includedValues.length > 0}
onChange={() => changeValues(allValuesSelected ? [] : allValues)}
/>
<Divider mt="-8%" />
{data && data.map((d) => (
<Checkbox
{...(!includedValues.includes(d.value) && { c: "dimmed" })}
key={d.value}
label={d.label}
checked={includedValues.includes(d.value)}
onChange={() => toggleValue(d.value)}
/>
))}
</MantineProvider>
</Stack>
)
}
156 changes: 156 additions & 0 deletions src/components/CreateExport/CreateExportModal.tsx
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<boolean>>
}) {
const project = useCanvasStore((state) => state.selectedProject);
const boardId = useCanvasStore((state) => state.selectedProjectBoardIds)[0]

const { data: issues } = useQuery<unknown, unknown, Issue[]>({
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<string[]>([]);
const [includedIssueStatus, setIncludedIssueStatus] = useState<string[]>([]);
const [issuesToExport, setIssuesToExport] = useState<Issue[]>([]);

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 (
<Modal
opened={opened}
onClose={() => {
setIncludedIssueTypes([]);
setIncludedIssueStatus([]);
setOpened(false);
}}
centered
withCloseButton={false}
size="40vw"
>
<Stack
align="left"
gap={0}
style={{
minHeight: "100%",
minWidth: "100%",
}}>
<Group c="dimmed" mb="5%">
<Text>{project?.name}</Text>
<Tooltip
withArrow
multiline
w={150}
fz={14}
fw={500}
openDelay={200}
closeDelay={200}
ta="center"
color="primaryBlue"
variant="filled"
label="Only issues with corresponding types and a 'Done' status are exported. The remaining status influence the date calculations."
>
<ActionIcon variant="subtle" ml="auto">
<IconInfoCircle />
</ActionIcon>
</Tooltip>
</Group>
<Paper shadow="md" radius="md" withBorder mb="5%">
<Group align ="top" justify="center" mb="5%">
<Stack align="center" mr="5%">
<Text size="md" fw={450} mt="7%" mb="10%" >Include Issue Types</Text>
{issueTypes && (
<CheckboxStack
data={issueTypes.map((issueType) => ({
value: issueType.name!,
label: issueType.name!,
}))}
onChange={(includedTypes) => setIncludedIssueTypes(includedTypes)}
/>
)}
</Stack>
<Stack align="center">
<Text size="md" fw={450} mt="7%" mb="10%">Include Issue Status</Text>
{allStatus && (
<CheckboxStack
data={allStatus.map((status) => ({
value: status.name,
label: status.name,
}))}
onChange={(includedStatus) => setIncludedIssueStatus(includedStatus)}
/>
)}
</Stack>
</Group>
</Paper>
<Group>
<Text size="90%" c="dimmed">
Issues to export: {issuesToExport.length}
</Text>
<Button
ml="auto"
size="sm"
onClick={() => { exportIssues(issuesToExport) }}
>
Export CSV
</Button>
</Group>
</Stack>
</Modal>
)
}
Loading

0 comments on commit ce7b86e

Please sign in to comment.