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

Support export of issues #115

Merged
merged 31 commits into from
Jan 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e4c9ae6
Add "Export" button.
ValsiRod Jan 10, 2024
54715b5
Add Export Modal
JulianRupprecht Jan 10, 2024
9c6425b
Added Export UI
JulianRupprecht Jan 11, 2024
2e5e430
Added export filter for issues and support for live display of the am…
JulianRupprecht Jan 11, 2024
8d428c0
Changed design of information button
JulianRupprecht Jan 12, 2024
88a20a0
implemented export functionality (some todos left)
trueliquid Jan 12, 2024
b9dbacc
Merge remote-tracking branch 'origin/support-export-feature' into sup…
trueliquid Jan 12, 2024
63a832a
added success as reply to export request
trueliquid Jan 12, 2024
df2b98d
added epics to export and implemented selectAll button
aymka Jan 14, 2024
0fc1437
fixed bug in select all
aymka Jan 14, 2024
ac0935f
Fix two bugs with one slap
maximilianruesch Jan 15, 2024
167a5a9
Fix select all button behaviour
maximilianruesch Jan 15, 2024
f902f9f
Generate checkboxes for all issue types
maximilianruesch Jan 15, 2024
9f707ac
Refactor issue status and types
maximilianruesch Jan 15, 2024
03b3803
Highlight issue status and type on check
maximilianruesch Jan 15, 2024
a9fbe28
Include header in csv file
maximilianruesch Jan 15, 2024
211b9bc
Beautify info icon
maximilianruesch Jan 15, 2024
a698257
Adjust styling of export info
maximilianruesch Jan 15, 2024
a5b1bb2
Export only done status
maximilianruesch Jan 16, 2024
39843bc
Export all issues regardless of backlog state
maximilianruesch Jan 16, 2024
24b76dd
Export by status category instead of status
maximilianruesch Jan 16, 2024
ec6a98b
Use status type enum
maximilianruesch Jan 16, 2024
32ad65f
added reply notification on export
trueliquid Jan 16, 2024
31ea76d
Refactor checkbox stacks
maximilianruesch Jan 16, 2024
0a99923
Sort issues during export
maximilianruesch Jan 16, 2024
ffd7a91
Merge remote-tracking branch 'origin/support-export-feature' into sup…
maximilianruesch Jan 16, 2024
3a46386
Improve message handling for export
maximilianruesch Jan 16, 2024
2e13fe2
Beautify export code and add new fields
maximilianruesch Jan 16, 2024
f40682a
Make export more extendable
maximilianruesch Jan 16, 2024
15a18a3
Add a bit more explanation
maximilianruesch Jan 16, 2024
17b5583
Increase yarn network timeout
maximilianruesch Jan 16, 2024
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
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": "^3.1.5",
"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