Skip to content

Commit

Permalink
CSV Download (#128)
Browse files Browse the repository at this point in the history
* Change CSV builder to allow expansion of type mapper and add Customer CSV DTO

* Frontend: Export as CSV for Products and Customers
  • Loading branch information
christoment authored Dec 14, 2023
1 parent 5affa2d commit ac0385b
Show file tree
Hide file tree
Showing 21 changed files with 345 additions and 70 deletions.
6 changes: 6 additions & 0 deletions Src/Application/Common/Interfaces/ICsvBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Northwind.Application.Common.Interfaces;

public interface ICsvBuilder
{
Task<byte[]> GetCsvBytes<T>(IEnumerable<T> records);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using AutoMapper;
using Northwind.Application.Common.Mappings;
using Northwind.Domain.Customers;

namespace Northwind.Application.Customers.Queries.GetCustomersCsv;

public class CustomerCsvLookupDto : IMapFrom<Customer>
{
public required string Id { get; init; }
public required string Name { get; init; }

public void Mapping(Profile profile)
{
profile.CreateMap<Customer, CustomerCsvLookupDto>()
.ForMember(d => d.Id, opt => opt.MapFrom(s => s.Id.Value))
.ForMember(d => d.Name, opt => opt.MapFrom(s => s.CompanyName));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Northwind.Application.Customers.Queries.GetCustomersCsv;

public class CustomersCsvVm
{
public required byte[] Data { get; set; }
public required string FileName { get; set; }
public readonly string ContentType = "text/csv";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using AutoMapper;
using AutoMapper.QueryableExtensions;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Northwind.Application.Common.Interfaces;

namespace Northwind.Application.Customers.Queries.GetCustomersCsv;

public sealed record GetCustomersCsvQuery : IRequest<CustomersCsvVm>;

public sealed class GetCustomersCsvQueryHandler(
INorthwindDbContext context,
IMapper mapper,
IDateTime dateTime,
ICsvBuilder csvBuilder) : IRequestHandler<GetCustomersCsvQuery, CustomersCsvVm>
{
public async Task<CustomersCsvVm> Handle(GetCustomersCsvQuery request, CancellationToken cancellationToken)
{
IEnumerable<CustomerCsvLookupDto> customers = await context.Customers
.ProjectTo<CustomerCsvLookupDto>(mapper.ConfigurationProvider)
.ToListAsync(cancellationToken);

byte[] data = await csvBuilder.GetCsvBytes(customers);

return new CustomersCsvVm
{
Data = data,
FileName = $"{dateTime.Now:yyyy-MM-dd}-Products.csv",
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@

namespace Northwind.Application.Products.Queries.GetProductsFile;

public record GetProductsFileQuery : IRequest<ProductsFileVm>;
public sealed record GetProductsFileQuery : IRequest<ProductsFileVm>;

// ReSharper disable once UnusedType.Global
public class GetProductsFileQueryHandler(INorthwindDbContext context, ICsvFileBuilder fileBuilder, IMapper mapper,
public sealed class GetProductsFileQueryHandler(INorthwindDbContext context, ICsvBuilder fileBuilder, IMapper mapper,
IDateTime dateTime)
: IRequestHandler<GetProductsFileQuery, ProductsFileVm>
{
Expand All @@ -22,7 +21,7 @@ public async Task<ProductsFileVm> Handle(GetProductsFileQuery request, Cancellat
.ProjectTo<ProductRecordDto>(mapper.ConfigurationProvider)
.ToListAsync(cancellationToken);

var fileContent = fileBuilder.BuildProductsFile(records);
var fileContent = await fileBuilder.GetCsvBytes(records);

var vm = new ProductsFileVm
{
Expand Down

This file was deleted.

2 changes: 1 addition & 1 deletion Src/Infrastructure/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi

private static void AddFiles(IServiceCollection services)
{
services.AddTransient<ICsvFileBuilder, CsvFileBuilder>();
services.AddTransient<ICsvBuilder, CsvBuilder>();
}

private static void AddServices(IServiceCollection services)
Expand Down
21 changes: 21 additions & 0 deletions Src/Infrastructure/Files/CsvBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using CsvHelper;
using Northwind.Application.Common.Interfaces;
using System.Globalization;

namespace Northwind.Infrastructure.Files;

public class CsvBuilder : ICsvBuilder
{
public Task<byte[]> GetCsvBytes<T>(IEnumerable<T> records)
{
using var stream = new MemoryStream();
using var streamWriter = new StreamWriter(stream);
using (var csvWriter = new CsvWriter(streamWriter, CultureInfo.InvariantCulture))
{
csvWriter.Context.ConfigureMappingProvider<T>();
csvWriter.WriteRecords(records);
}

return Task.FromResult(stream.ToArray());
}
}
21 changes: 0 additions & 21 deletions Src/Infrastructure/Files/CsvFileBuilder.cs

This file was deleted.

20 changes: 20 additions & 0 deletions Src/Infrastructure/Files/CsvMapProviders.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using CsvHelper;
using Northwind.Application.Customers.Queries.GetCustomersCsv;
using Northwind.Application.Products.Queries.GetProductsFile;

namespace Northwind.Infrastructure.Files;

public static class CsvMapProviders
{
private static readonly IReadOnlyDictionary<Type, Action<CsvContext>> TypeConfiguration = new Dictionary<Type, Action<CsvContext>>
{
{ typeof(ProductRecordDto), context => context.RegisterClassMap<ProductFileRecordMap>() },
{ typeof(CustomerCsvLookupDto), context => context.RegisterClassMap<CustomerFileRecordMap>() },
};

public static void ConfigureMappingProvider<T>(
this CsvContext csvContext)
{
TypeConfiguration.GetValueOrDefault(typeof(T))?.Invoke(csvContext);
}
}
13 changes: 13 additions & 0 deletions Src/Infrastructure/Files/CustomerFileRecordMap.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using CsvHelper.Configuration;
using Northwind.Application.Customers.Queries.GetCustomersCsv;
using System.Globalization;

namespace Northwind.Infrastructure.Files;

public sealed class CustomerFileRecordMap : ClassMap<CustomerCsvLookupDto>
{
public CustomerFileRecordMap()
{
AutoMap(CultureInfo.InvariantCulture);
}
}
40 changes: 40 additions & 0 deletions Src/WebUI/ClientApp/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Src/WebUI/ClientApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"angular-feather": "^6.0.2",
"aspnet-prerendering": "^3.0.1",
"bootstrap": "^5.2.3",
"file-saver": "^2.0.5",
"jquery": "^3.6.4",
"ngx-bootstrap": "^5.1.1",
"popper.js": "^1.16.0",
Expand All @@ -40,6 +41,7 @@
"@angular-devkit/build-angular": "^15.2.7",
"@angular/cli": "^15.2.7",
"@angular/compiler-cli": "^15.2.8",
"@types/file-saver": "^2.0.7",
"@types/jasmine": "~4.3.1",
"@types/jasminewd2": "~2.0.10",
"@types/node": "^18.16.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
<h1 class="h2">Customers</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group mr-2">
<button class="btn btn-sm btn-outline-secondary">Share</button>
<button class="btn btn-sm btn-outline-secondary">Export</button>
<button class="btn btn-sm btn-outline-secondary"
(click)="exportAsCsv()">
Export
</button>
</div>
</div>
</div>
Expand Down
29 changes: 19 additions & 10 deletions Src/WebUI/ClientApp/src/app/customers/customers.component.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,38 @@
import { Component } from '@angular/core';
import { Client, CustomerDetailVm, CustomersListVm } from '../northwind-traders-api';
import { Component, inject, OnInit } from '@angular/core';
import { Client, CustomersListVm } from '../northwind-traders-api';
import { CustomerDetailComponent } from '../customer-detail/customer-detail.component';
import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
import { BsModalService } from 'ngx-bootstrap/modal';
import { saveAs } from 'file-saver';

@Component({
selector: 'app-customers',
templateUrl: './customers.component.html'
})
export class CustomersComponent {
export class CustomersComponent implements OnInit {
private client = inject(Client);
private modalService =inject(BsModalService);

public vm: CustomersListVm = new CustomersListVm();
private bsModalRef: BsModalRef;

constructor(private client: Client, private modalService: BsModalService) {
client.getCustomersList().subscribe(result => {
ngOnInit(): void {
this.client.getCustomersList().subscribe(result => {
this.vm = result;
}, error => console.error(error));
});
}

public customerDetail(id: string) {
this.client.getCustomer(id).subscribe(result => {
const initialState = {
customer: result
};
this.bsModalRef = this.modalService.show(CustomerDetailComponent, {initialState});
}, error => console.error(error));
this.modalService.show(CustomerDetailComponent, {initialState});
});
}

protected exportAsCsv() {
this.client.getCustomersCsv().subscribe(result => {
const blob = new Blob([result.data], { type: result.headers.contentType });
saveAs(blob, result.fileName);
});
}
}
Loading

0 comments on commit ac0385b

Please sign in to comment.