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

Add csv export #1725

Open
wants to merge 32 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
475764f
Add CSV Export
MiraGeowerkstatt Dec 5, 2024
f97a02d
Add alert for more than 100 export
MiraGeowerkstatt Dec 5, 2024
c4078b1
Add button translations
MiraGeowerkstatt Dec 5, 2024
d58a433
Fix prompt condition
MiraGeowerkstatt Dec 5, 2024
89c75b4
Add cypress test
MiraGeowerkstatt Dec 5, 2024
d822b76
Fix merge error
MiraGeowerkstatt Dec 5, 2024
ec3b10d
Rename properties for csv Export
MiraGeowerkstatt Dec 5, 2024
25d02e8
Fix alternate Name on client
MiraGeowerkstatt Dec 5, 2024
08e7ced
Fix name import
MiraGeowerkstatt Dec 5, 2024
21b81ce
Rename props on client
MiraGeowerkstatt Dec 5, 2024
a1f4540
Fix test expectation
MiraGeowerkstatt Dec 5, 2024
c029b26
Fix typo
MiraGeowerkstatt Dec 5, 2024
56493a7
remove duplicated code
MiraGeowerkstatt Dec 5, 2024
6120cbc
write csv async
MiraGeowerkstatt Dec 5, 2024
60a7027
Fix function signature
MiraGeowerkstatt Dec 5, 2024
2d52126
Remove log
MiraGeowerkstatt Dec 5, 2024
82cbc5c
Return bad request on one line
MiraGeowerkstatt Dec 10, 2024
2b3da9f
Remove whitespace
MiraGeowerkstatt Dec 10, 2024
9bbea08
Rename method
MiraGeowerkstatt Dec 10, 2024
11ee187
Fix indentation
MiraGeowerkstatt Dec 10, 2024
e893b2b
Add test cases
MiraGeowerkstatt Dec 10, 2024
93e1ea9
Fix whitespace error
MiraGeowerkstatt Dec 10, 2024
1025a89
Use max page size
MiraGeowerkstatt Dec 10, 2024
880e6c5
Merge branch 'main' into add-csv-export
MiraGeowerkstatt Dec 10, 2024
009151d
Fix spaces in doc
MiraGeowerkstatt Dec 10, 2024
e2f683b
Use maxPage Size in function doc
MiraGeowerkstatt Dec 10, 2024
bd5de35
Use functions to get filesnames
MiraGeowerkstatt Dec 10, 2024
6d8c0ba
Use String writer
MiraGeowerkstatt Dec 10, 2024
4fa1a79
Update changelog
MiraGeowerkstatt Dec 10, 2024
b6c18be
Merge branch 'main' into add-csv-export
MiraGeowerkstatt Dec 10, 2024
216b7c1
Merge branch 'main' into add-csv-export
MiraGeowerkstatt Dec 10, 2024
26fddc4
Fix spelling
MiraGeowerkstatt Dec 11, 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
81 changes: 74 additions & 7 deletions src/api/Controllers/BoreholeController.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
using BDMS.Authentication;
using BDMS.Models;
using CsvHelper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NetTopologySuite.Geometries;
using System.ComponentModel.DataAnnotations;
using System.Globalization;

namespace BDMS.Controllers;

Expand Down Expand Up @@ -77,7 +79,7 @@ public async override Task<ActionResult<Borehole>> EditAsync(Borehole entity)
/// <param name="pageSize">The page size for pagination.</param>
[HttpGet]
[Authorize(Policy = PolicyNames.Viewer)]
public async Task<ActionResult<PaginatedBoreholeResponse>> GetAllAsync([FromQuery][MaxLength(MaxPageSize)] IEnumerable<int>? ids = null, [FromQuery][Range(1, int.MaxValue)] int pageNumber = 1, [FromQuery] [Range(1, MaxPageSize)] int pageSize = 100)
public async Task<ActionResult<PaginatedBoreholeResponse>> GetAllAsync([FromQuery][MaxLength(MaxPageSize)] IEnumerable<int>? ids = null, [FromQuery][Range(1, int.MaxValue)] int pageNumber = 1, [FromQuery][Range(1, MaxPageSize)] int pageSize = 100)
{
pageSize = Math.Min(MaxPageSize, Math.Max(1, pageSize));

Expand Down Expand Up @@ -142,6 +144,71 @@ private IQueryable<Borehole> GetBoreholesWithIncludes()
.Include(b => b.UpdatedBy);
}

/// <summary>
/// Exports the details of up to 100 boreholes as a CSV file. Filters the boreholes based on the provided list of IDs.
MiraGeowerkstatt marked this conversation as resolved.
Show resolved Hide resolved
/// </summary>
/// <param name="ids">The list of IDs for the boreholes to be exported.</param>
/// <returns>A CSV file containing the details of up to 100 specified boreholes.</returns>
[HttpGet("export-csv")]
[Authorize(Policy = PolicyNames.Viewer)]
public async Task<IActionResult> DownloadCsv([FromQuery] IEnumerable<int> ids)
MiraGeowerkstatt marked this conversation as resolved.
Show resolved Hide resolved
MiraGeowerkstatt marked this conversation as resolved.
Show resolved Hide resolved
{
if (!ids.Any())
{
return BadRequest("The list of IDs must not be empty.");
}
MiraGeowerkstatt marked this conversation as resolved.
Show resolved Hide resolved
Logger.LogInformation("Export borehole(s) with ids <{Ids}>.", string.Join(", ", ids));
Fixed Show fixed Hide fixed

var boreholes = await Context.Boreholes
.Where(borehole => ids.Contains(borehole.Id))
.Take(100)
MiraGeowerkstatt marked this conversation as resolved.
Show resolved Hide resolved
MiraGeowerkstatt marked this conversation as resolved.
Show resolved Hide resolved
.Select(b => new
{
b.Id,
b.OriginalName,
b.ProjectName,
b.AlternateName,
b.RestrictionId,
b.RestrictionUntil,
b.NationalInterest,
b.LocationX,
b.LocationY,
b.LocationPrecisionId,
b.ElevationZ,
b.ElevationPrecisionId,
b.ReferenceElevation,
b.ReferenceElevationTypeId,
b.QtReferenceElevationId,
b.HrsId,
b.TypeId,
b.PurposeId,
b.StatusId,
b.Remarks,
b.TotalDepth,
b.QtDepthId,
b.TopBedrockFreshMd,
b.TopBedrockWeatheredMd,
b.HasGroundwater,
b.LithologyTopBedrockId,
b.ChronostratigraphyId,
b.LithostratigraphyId,

})
.ToListAsync()
.ConfigureAwait(false);

var stream = new MemoryStream();
using (var writer = new StreamWriter(stream, leaveOpen: true))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQ: Hier kann die StringWriter-Klasse verwendet werden. So muss man sich nicht mit dem MemoryStream herumschlagen.
Beispiel:

using var stringWriter = new StringWriter();
using var csvWriter = new CsvWriter(stringWriter , CultureInfo.InvariantCulture)return File(stringWriter.ToString(),

using (var csvWriter = new CsvWriter(writer, CultureInfo.InvariantCulture))
{
csvWriter.WriteRecords(boreholes);
}

stream.Position = 0;

return File(stream.ToArray(), "text/csv", "boreholes_export.csv");
}

/// <summary>
/// Asynchronously copies a <see cref="Borehole"/>.
/// </summary>
Expand Down Expand Up @@ -179,7 +246,7 @@ public async Task<ActionResult<int>> CopyAsync([Required] int id, [Required] int
{
// Include FieldMeasurementResults and HydrotestResults separately since Entity Framework does not support casting in an Include statement
var fieldMeasurements = borehole.Observations.OfType<FieldMeasurement>().ToList();
#pragma warning disable CS8603
#pragma warning disable CS8603
// Cannot include null test for fieldMeasurementResults and hydrotestResults since they are not yet loaded
// if there are no fieldMeasurementResults of hydrotestResults the LoadAsync method will be called but have no effect
foreach (var fieldMeasurement in fieldMeasurements)
Expand All @@ -193,12 +260,12 @@ await Context.Entry(fieldMeasurement)
var hydrotests = borehole.Observations.OfType<Hydrotest>().ToList();
foreach (var hydrotest in hydrotests)
{
await Context.Entry(hydrotest)
.Collection(h => h.HydrotestResults)
.LoadAsync()
.ConfigureAwait(false);
await Context.Entry(hydrotest)
.Collection(h => h.HydrotestResults)
.LoadAsync()
.ConfigureAwait(false);
}
#pragma warning restore CS8603
#pragma warning restore CS8603
}

// Set ids of copied entities to zero. Entities with an id of zero are added as new entities to the DB.
Expand Down
5 changes: 5 additions & 0 deletions src/client/src/api/borehole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,8 @@ export const getAllBoreholes = async (ids: number[] | GridRowSelectionModel, pag
const idsQuery = ids.map(id => `ids=${id}`).join("&");
return await fetchApiV2(`borehole?${idsQuery}&pageNumber=${pageNumber}&pageSize=${pageSize}`, "GET");
};

export const exportCSVBorehole = async (boreholeIds: GridRowSelectionModel) => {
const idsQuery = boreholeIds.map(id => `ids=${id}`).join("&");
return await fetchApiV2(`borehole/export-csv?${idsQuery}`, "GET");
};
2 changes: 1 addition & 1 deletion src/client/src/components/buttons/buttons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export const ExportButton = forwardRef<HTMLButtonElement, ButtonProps>((props, r
<BdmsBaseButton
ref={ref}
{...props}
label="export"
label={props.label ?? "export"}
variant={props.variant ?? "outlined"}
color={props.color ?? "secondary"}
icon={<ArrowDownToLine />}
Expand Down
24 changes: 11 additions & 13 deletions src/client/src/pages/detail/detailHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useHistory } from "react-router-dom";
import { Chip, Stack, Typography } from "@mui/material";
import { Check, Trash2, X } from "lucide-react";
import { deleteBorehole, lockBorehole, unlockBorehole } from "../../api-lib";
import { BoreholeV2 } from "../../api/borehole.ts";
import { BoreholeV2, exportCSVBorehole } from "../../api/borehole.ts";
import { useAuth } from "../../auth/useBdmsAuth.tsx";
import {
DeleteButton,
Expand All @@ -17,6 +17,7 @@ import {
import DateText from "../../components/legacyComponents/dateText";
import { PromptContext } from "../../components/prompt/promptContext.tsx";
import { DetailHeaderStack } from "../../components/styledComponents.ts";
import { downloadData } from "../../utils.ts";
import { useFormDirty } from "./useFormDirty.tsx";

interface DetailHeaderProps {
Expand Down Expand Up @@ -84,18 +85,14 @@ const DetailHeader = ({
history.push("/");
};

const handleExport = () => {
const handleJsonExport = () => {
const jsonString = JSON.stringify([borehole], null, 2);
const blob = new Blob([jsonString], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
const name_without_spaces = borehole.alternateName.replace(/\s/g, "_");
link.download = `${name_without_spaces}.json`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
downloadData(jsonString, borehole.alternateName.replace(/\s/g, "_"), "application/json");
};

const handleCSVExport = async () => {
const csvData = await exportCSVBorehole([borehole.id]);
downloadData(csvData, borehole.alternateName.replace(/\s/g, "_"), "text/csv");
};

return (
Expand Down Expand Up @@ -150,7 +147,8 @@ const DetailHeader = ({
</>
) : (
<>
<ExportButton onClick={handleExport} />
<ExportButton label="exportJson" onClick={handleJsonExport} />
<ExportButton label="exportCSV" onClick={handleCSVExport} />
<EditButton onClick={startEditing} />
</>
))}
Expand Down
9 changes: 6 additions & 3 deletions src/client/src/pages/overview/boreholeTable/bottomBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ interface BottomBarProps {
search: { filter: string };
onDeleteMultiple: () => void;
onCopyBorehole: () => void;
onExportMultiple: () => void;
onExportMultipleJson: () => void;
onExportMultipleCsv: () => void;
workgroup: string;
setWorkgroup: React.Dispatch<React.SetStateAction<string>>;
}
Expand All @@ -31,7 +32,8 @@ const BottomBar = ({
onDeleteMultiple,
search,
onCopyBorehole,
onExportMultiple,
onExportMultipleJson,
onExportMultipleCsv,
boreholes,
workgroup,
setWorkgroup,
Expand Down Expand Up @@ -115,7 +117,8 @@ const BottomBar = ({
<CopyButton color="secondary" onClick={() => showCopyPromptForSelectedWorkgroup()} />
)}
<BulkEditButton label={"bulkEditing"} onClick={bulkEditSelected} />
<ExportButton label={"export"} onClick={onExportMultiple} />
<ExportButton label={"exportJson"} onClick={onExportMultipleJson} />
<ExportButton label={"exportCSV"} onClick={onExportMultipleCsv} />
<Typography variant="subtitle1"> {t("selectedCount", { count: selectionModel.length })}</Typography>
</Stack>
) : (
Expand Down
23 changes: 11 additions & 12 deletions src/client/src/pages/overview/boreholeTable/bottomBarContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { useHistory } from "react-router-dom";
import { GridRowSelectionModel, GridSortDirection, GridSortModel } from "@mui/x-data-grid";
import { deleteBoreholes } from "../../../api-lib";
import { Boreholes, ReduxRootState, User } from "../../../api-lib/ReduxStateInterfaces.ts";
import { copyBorehole, getAllBoreholes } from "../../../api/borehole.ts";
import { copyBorehole, exportCSVBorehole, getAllBoreholes } from "../../../api/borehole.ts";
import { downloadData } from "../../../utils.ts";
import { OverViewContext } from "../overViewContext.tsx";
import { FilterContext } from "../sidePanelContent/filter/filterContext.tsx";
import { BoreholeTable } from "./boreholeTable.tsx";
Expand Down Expand Up @@ -88,18 +89,15 @@ const BottomBarContainer = ({
setIsBusy(false);
};

const onExportMultiple = async () => {
const onExportMultipleJson = async () => {
const paginatedResponse = await getAllBoreholes(selectionModel, 1, selectionModel.length);
const jsonString = JSON.stringify(paginatedResponse.boreholes, null, 2);
const blob = new Blob([jsonString], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `bulkexport_${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
downloadData(jsonString, `bulkexport_${new Date().toISOString().split("T")[0]}`, "application/json");
};

const onExportMultipleCsv = async () => {
const csvData = await exportCSVBorehole(selectionModel);
downloadData(csvData, `bulkexport__${new Date().toISOString().split("T")[0]}`, "text/csv");
};

return (
Expand All @@ -109,7 +107,8 @@ const BottomBarContainer = ({
multipleSelected={multipleSelected}
onCopyBorehole={onCopyBorehole}
onDeleteMultiple={onDeleteMultiple}
onExportMultiple={onExportMultiple}
onExportMultipleJson={onExportMultipleJson}
onExportMultipleCsv={onExportMultipleCsv}
search={search}
boreholes={boreholes}
workgroup={workgroupId}
Expand Down
12 changes: 12 additions & 0 deletions src/client/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,15 @@ export function capitalizeFirstLetter(text: string) {
if (!text) return "";
return text.charAt(0).toUpperCase() + text.slice(1);
}

export const downloadData = (dataString: string, fileName: string, type: string) => {
const blob = new Blob([dataString], { type: type });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
29 changes: 29 additions & 0 deletions tests/api/Controllers/BoreholeControllerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System.Text;
using static BDMS.Helpers;

namespace BDMS.Controllers;
Expand Down Expand Up @@ -550,4 +551,32 @@ public async Task CopyWithNonAdminUser()
Assert.IsNotNull(copiedBoreholeId);
Assert.IsInstanceOfType(copiedBoreholeId, typeof(int));
}

[TestMethod]
MiraGeowerkstatt marked this conversation as resolved.
Show resolved Hide resolved
public async Task DownloadCsvWithValidIdsReturnsFileResultWithMax100Boreholes()
{
var ids = Enumerable.Range(testBoreholeId, 120).ToList();

var result = await controller.DownloadCsv(ids) as FileContentResult;

Assert.IsNotNull(result);
Assert.AreEqual("text/csv", result.ContentType);
Assert.AreEqual("boreholes.csv", result.FileDownloadName);
var csvData = Encoding.UTF8.GetString(result.FileContents);
var fileLength = csvData.Split('\n').Length;
var recordCount = fileLength - 2; // Remove header and last line break
Assert.IsTrue(recordCount <= 100);
}

[TestMethod]
public async Task DownloadCsvEmptyIdsReturnsBadRequest()
{
var ids = new List<int>();

var result = await controller.DownloadCsv(ids) as BadRequestObjectResult;

Assert.IsNotNull(result);
Assert.AreEqual("The list of IDs must not be empty.", result.Value);
}

}