From 81efb3a943c501e12efca9faf52e18fa87f3670b Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 14 Dec 2023 15:06:23 +1100 Subject: [PATCH 1/2] Change CSV builder to allow expansion of type mapper and add Customer CSV DTO --- .../Common/Interfaces/ICsvBuilder.cs | 6 + .../GetCustomersCsv/CustomerCsvLookupDto.cs | 18 +++ .../Queries/GetCustomersCsv/CustomersCsvVm.cs | 8 ++ .../GetCustomersCsv/GetCustomersCsvQuery.cs | 31 ++++++ .../GetProductsFile/GetProductsFileQuery.cs | 7 +- .../GetProductsFile/ICsvFileBuilder.cs | 6 - Src/Infrastructure/DependencyInjection.cs | 2 +- Src/Infrastructure/Files/CsvBuilder.cs | 21 ++++ Src/Infrastructure/Files/CsvFileBuilder.cs | 21 ---- Src/Infrastructure/Files/CsvMapProviders.cs | 20 ++++ .../Files/CustomerFileRecordMap.cs | 13 +++ .../src/app/northwind-traders-api.ts | 105 +++++++++++++++--- Src/WebUI/Features/CustomerEndpoints.cs | 10 ++ Src/WebUI/Features/ProductsController.cs | 5 +- Src/WebUI/wwwroot/api/specification.json | 41 ++++++- 15 files changed, 262 insertions(+), 52 deletions(-) create mode 100644 Src/Application/Common/Interfaces/ICsvBuilder.cs create mode 100644 Src/Application/Customers/Queries/GetCustomersCsv/CustomerCsvLookupDto.cs create mode 100644 Src/Application/Customers/Queries/GetCustomersCsv/CustomersCsvVm.cs create mode 100644 Src/Application/Customers/Queries/GetCustomersCsv/GetCustomersCsvQuery.cs delete mode 100644 Src/Application/Products/Queries/GetProductsFile/ICsvFileBuilder.cs create mode 100644 Src/Infrastructure/Files/CsvBuilder.cs delete mode 100644 Src/Infrastructure/Files/CsvFileBuilder.cs create mode 100644 Src/Infrastructure/Files/CsvMapProviders.cs create mode 100644 Src/Infrastructure/Files/CustomerFileRecordMap.cs diff --git a/Src/Application/Common/Interfaces/ICsvBuilder.cs b/Src/Application/Common/Interfaces/ICsvBuilder.cs new file mode 100644 index 00000000..6dde0387 --- /dev/null +++ b/Src/Application/Common/Interfaces/ICsvBuilder.cs @@ -0,0 +1,6 @@ +namespace Northwind.Application.Common.Interfaces; + +public interface ICsvBuilder +{ + Task GetCsvBytes(IEnumerable records); +} \ No newline at end of file diff --git a/Src/Application/Customers/Queries/GetCustomersCsv/CustomerCsvLookupDto.cs b/Src/Application/Customers/Queries/GetCustomersCsv/CustomerCsvLookupDto.cs new file mode 100644 index 00000000..790075db --- /dev/null +++ b/Src/Application/Customers/Queries/GetCustomersCsv/CustomerCsvLookupDto.cs @@ -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 +{ + public required string Id { get; init; } + public required string Name { get; init; } + + public void Mapping(Profile profile) + { + profile.CreateMap() + .ForMember(d => d.Id, opt => opt.MapFrom(s => s.Id.Value)) + .ForMember(d => d.Name, opt => opt.MapFrom(s => s.CompanyName)); + } +} \ No newline at end of file diff --git a/Src/Application/Customers/Queries/GetCustomersCsv/CustomersCsvVm.cs b/Src/Application/Customers/Queries/GetCustomersCsv/CustomersCsvVm.cs new file mode 100644 index 00000000..284bfc15 --- /dev/null +++ b/Src/Application/Customers/Queries/GetCustomersCsv/CustomersCsvVm.cs @@ -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"; +} \ No newline at end of file diff --git a/Src/Application/Customers/Queries/GetCustomersCsv/GetCustomersCsvQuery.cs b/Src/Application/Customers/Queries/GetCustomersCsv/GetCustomersCsvQuery.cs new file mode 100644 index 00000000..affdaee6 --- /dev/null +++ b/Src/Application/Customers/Queries/GetCustomersCsv/GetCustomersCsvQuery.cs @@ -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; + +public sealed class GetCustomersCsvQueryHandler( + INorthwindDbContext context, + IMapper mapper, + IDateTime dateTime, + ICsvBuilder csvBuilder) : IRequestHandler +{ + public async Task Handle(GetCustomersCsvQuery request, CancellationToken cancellationToken) + { + IEnumerable customers = await context.Customers + .ProjectTo(mapper.ConfigurationProvider) + .ToListAsync(cancellationToken); + + byte[] data = await csvBuilder.GetCsvBytes(customers); + + return new CustomersCsvVm + { + Data = data, + FileName = $"{dateTime.Now:yyyy-MM-dd}-Products.csv", + }; + } +} \ No newline at end of file diff --git a/Src/Application/Products/Queries/GetProductsFile/GetProductsFileQuery.cs b/Src/Application/Products/Queries/GetProductsFile/GetProductsFileQuery.cs index a6d47eee..836713b9 100644 --- a/Src/Application/Products/Queries/GetProductsFile/GetProductsFileQuery.cs +++ b/Src/Application/Products/Queries/GetProductsFile/GetProductsFileQuery.cs @@ -9,10 +9,9 @@ namespace Northwind.Application.Products.Queries.GetProductsFile; -public record GetProductsFileQuery : IRequest; +public sealed record GetProductsFileQuery : IRequest; -// 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 { @@ -22,7 +21,7 @@ public async Task Handle(GetProductsFileQuery request, Cancellat .ProjectTo(mapper.ConfigurationProvider) .ToListAsync(cancellationToken); - var fileContent = fileBuilder.BuildProductsFile(records); + var fileContent = await fileBuilder.GetCsvBytes(records); var vm = new ProductsFileVm { diff --git a/Src/Application/Products/Queries/GetProductsFile/ICsvFileBuilder.cs b/Src/Application/Products/Queries/GetProductsFile/ICsvFileBuilder.cs deleted file mode 100644 index c70ba95c..00000000 --- a/Src/Application/Products/Queries/GetProductsFile/ICsvFileBuilder.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Northwind.Application.Products.Queries.GetProductsFile; - -public interface ICsvFileBuilder -{ - byte[] BuildProductsFile(IEnumerable records); -} \ No newline at end of file diff --git a/Src/Infrastructure/DependencyInjection.cs b/Src/Infrastructure/DependencyInjection.cs index 8fcdcbd3..d57fca72 100644 --- a/Src/Infrastructure/DependencyInjection.cs +++ b/Src/Infrastructure/DependencyInjection.cs @@ -26,7 +26,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi private static void AddFiles(IServiceCollection services) { - services.AddTransient(); + services.AddTransient(); } private static void AddServices(IServiceCollection services) diff --git a/Src/Infrastructure/Files/CsvBuilder.cs b/Src/Infrastructure/Files/CsvBuilder.cs new file mode 100644 index 00000000..62ffb66d --- /dev/null +++ b/Src/Infrastructure/Files/CsvBuilder.cs @@ -0,0 +1,21 @@ +using CsvHelper; +using Northwind.Application.Common.Interfaces; +using System.Globalization; + +namespace Northwind.Infrastructure.Files; + +public class CsvBuilder : ICsvBuilder +{ + public Task GetCsvBytes(IEnumerable records) + { + using var stream = new MemoryStream(); + using var streamWriter = new StreamWriter(stream); + using (var csvWriter = new CsvWriter(streamWriter, CultureInfo.InvariantCulture)) + { + csvWriter.Context.ConfigureMappingProvider(); + csvWriter.WriteRecords(records); + } + + return Task.FromResult(stream.ToArray()); + } +} \ No newline at end of file diff --git a/Src/Infrastructure/Files/CsvFileBuilder.cs b/Src/Infrastructure/Files/CsvFileBuilder.cs deleted file mode 100644 index 7de8a258..00000000 --- a/Src/Infrastructure/Files/CsvFileBuilder.cs +++ /dev/null @@ -1,21 +0,0 @@ -using CsvHelper; -using Northwind.Application.Products.Queries.GetProductsFile; -using System.Globalization; - -namespace Northwind.Infrastructure.Files; - -public class CsvFileBuilder : ICsvFileBuilder -{ - public byte[] BuildProductsFile(IEnumerable records) - { - using var memoryStream = new MemoryStream(); - using (var streamWriter = new StreamWriter(memoryStream)) - using (var csvWriter = new CsvWriter(streamWriter, CultureInfo.InvariantCulture)) - { - csvWriter.Context.RegisterClassMap(); - csvWriter.WriteRecords(records); - } - - return memoryStream.ToArray(); - } -} \ No newline at end of file diff --git a/Src/Infrastructure/Files/CsvMapProviders.cs b/Src/Infrastructure/Files/CsvMapProviders.cs new file mode 100644 index 00000000..9fe2afd7 --- /dev/null +++ b/Src/Infrastructure/Files/CsvMapProviders.cs @@ -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> TypeConfiguration = new Dictionary> + { + { typeof(ProductRecordDto), context => context.RegisterClassMap() }, + { typeof(CustomerCsvLookupDto), context => context.RegisterClassMap() }, + }; + + public static void ConfigureMappingProvider( + this CsvContext csvContext) + { + TypeConfiguration.GetValueOrDefault(typeof(T))?.Invoke(csvContext); + } +} \ No newline at end of file diff --git a/Src/Infrastructure/Files/CustomerFileRecordMap.cs b/Src/Infrastructure/Files/CustomerFileRecordMap.cs new file mode 100644 index 00000000..c157c751 --- /dev/null +++ b/Src/Infrastructure/Files/CustomerFileRecordMap.cs @@ -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 +{ + public CustomerFileRecordMap() + { + AutoMap(CultureInfo.InvariantCulture); + } +} \ No newline at end of file diff --git a/Src/WebUI/ClientApp/src/app/northwind-traders-api.ts b/Src/WebUI/ClientApp/src/app/northwind-traders-api.ts index 3b7471f0..c70546f8 100644 --- a/Src/WebUI/ClientApp/src/app/northwind-traders-api.ts +++ b/Src/WebUI/ClientApp/src/app/northwind-traders-api.ts @@ -29,13 +29,14 @@ export interface IClient { getCategoriesList(): Observable; getCustomersList(): Observable; createCustomer(command: CreateCustomerCommand): Observable; + getCustomersCsv(): Observable; getCustomer(id: string): Observable; updateCustomer(id: string, command: UpdateCustomerCommand): Observable; deleteCustomer(id: string): Observable; getProductsList(): Observable; createProduct(command: CreateProductCommand): Observable; updateProduct(command: UpdateProductCommand): Observable; - download(): Observable; + getProductsCsv(): Observable; getProductDetail(id: number): Observable; deleteProduct(id: number): Observable; } @@ -800,6 +801,69 @@ export class Client implements IClient { return _observableOf(null as any); } + getCustomersCsv(): Observable { + let url_ = this.baseUrl + "/api/customers/download"; + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + headers: new HttpHeaders({ + "Accept": "application/octet-stream" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processGetCustomersCsv(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processGetCustomersCsv(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processGetCustomersCsv(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers.get("content-disposition") : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return _observableOf({ fileName: fileName, data: responseBlob as any, status: status, headers: _headers }); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("A server side error occurred.", status, _responseText, _headers); + })); + } else if (status === 500) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result500: any = null; + let resultData500 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver); + result500 = ProblemDetails.fromJS(resultData500); + return throwException("A server side error occurred.", status, _responseText, _headers, result500); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + getCustomer(id: string): Observable { let url_ = this.baseUrl + "/api/customers/{id}"; if (id === undefined || id === null) @@ -1195,7 +1259,7 @@ export class Client implements IClient { return _observableOf(null as any); } - download(): Observable { + getProductsCsv(): Observable { let url_ = this.baseUrl + "/api/products/download"; url_ = url_.replace(/[?&]$/, ""); @@ -1203,38 +1267,42 @@ export class Client implements IClient { observe: "response", responseType: "blob", headers: new HttpHeaders({ - "Accept": "application/json" + "Accept": "application/octet-stream" }) }; return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { - return this.processDownload(response_); + return this.processGetProductsCsv(response_); })).pipe(_observableCatch((response_: any) => { if (response_ instanceof HttpResponseBase) { try { - return this.processDownload(response_ as any); + return this.processGetProductsCsv(response_ as any); } catch (e) { - return _observableThrow(e) as any as Observable; + return _observableThrow(e) as any as Observable; } } else - return _observableThrow(response_) as any as Observable; + return _observableThrow(response_) as any as Observable; })); } - protected processDownload(response: HttpResponseBase): Observable { + protected processGetProductsCsv(response: HttpResponseBase): Observable { const status = response.status; const responseBlob = response instanceof HttpResponse ? response.body : (response as any).error instanceof Blob ? (response as any).error : undefined; let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} - if (status === 200) { - return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { - let result200: any = null; - let resultData200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver); - result200 = ProductsListVm.fromJS(resultData200); - return _observableOf(result200); - })); + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers.get("content-disposition") : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return _observableOf({ fileName: fileName, data: responseBlob as any, status: status, headers: _headers }); } else if (status === 404) { return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { return throwException("A server side error occurred.", status, _responseText, _headers); @@ -2687,6 +2755,13 @@ export interface IUpdateProductCommand { discontinued?: boolean; } +export interface FileResponse { + data: Blob; + status: number; + fileName?: string; + headers?: { [name: string]: any }; +} + export class SwaggerException extends Error { override message: string; status: number; diff --git a/Src/WebUI/Features/CustomerEndpoints.cs b/Src/WebUI/Features/CustomerEndpoints.cs index e5f9289a..d530ef5c 100644 --- a/Src/WebUI/Features/CustomerEndpoints.cs +++ b/Src/WebUI/Features/CustomerEndpoints.cs @@ -4,6 +4,7 @@ using Northwind.Application.Customers.Commands.DeleteCustomer; using Northwind.Application.Customers.Commands.UpdateCustomer; using Northwind.Application.Customers.Queries.GetCustomerDetail; +using Northwind.Application.Customers.Queries.GetCustomersCsv; using Northwind.Application.Customers.Queries.GetCustomersList; using SSW.CleanArchitecture.WebApi.Extensions; @@ -22,6 +23,15 @@ public static void MapCustomerEndpoints(this WebApplication app) .WithName("GetCustomersList") .ProducesGet(); + group + .MapGet("/download", async (ISender sender, CancellationToken ct) => + { + CustomersCsvVm result = await sender.Send(new GetCustomersCsvQuery(), ct); + return TypedResults.File(result.Data, result.ContentType, result.FileName); + }) + .WithName("GetCustomersCsv") + .ProducesGet(); + group .MapGet("/{id}", (string id, ISender sender, CancellationToken ct) => sender.Send(new GetCustomerDetailQuery(id), ct)) diff --git a/Src/WebUI/Features/ProductsController.cs b/Src/WebUI/Features/ProductsController.cs index 68e99dd4..d06b850e 100644 --- a/Src/WebUI/Features/ProductsController.cs +++ b/Src/WebUI/Features/ProductsController.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.AspNetCore.Mvc; using Northwind.Application.Products.Commands.CreateProduct; using Northwind.Application.Products.Commands.DeleteProduct; using Northwind.Application.Products.Commands.UpdateProduct; @@ -28,8 +29,8 @@ public static void MapProductEndpoints(this WebApplication app) var file = await sender.Send(new GetProductsFileQuery(), ct); return TypedResults.File(file.Content, file.ContentType, file.FileName); }) - .WithName("download") - .ProducesGet(); + .WithName("GetProductsCsv") + .ProducesGet(); group .MapGet("/{id}", diff --git a/Src/WebUI/wwwroot/api/specification.json b/Src/WebUI/wwwroot/api/specification.json index 89389aa7..f98ca23b 100644 --- a/Src/WebUI/wwwroot/api/specification.json +++ b/Src/WebUI/wwwroot/api/specification.json @@ -471,6 +471,40 @@ } } }, + "/api/customers/download": { + "get": { + "tags": [ + "customers" + ], + "operationId": "GetCustomersCsv", + "responses": { + "200": { + "description": "", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "" + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/customers/{id}": { "get": { "tags": [ @@ -767,14 +801,15 @@ "tags": [ "products" ], - "operationId": "download", + "operationId": "GetProductsCsv", "responses": { "200": { "description": "", "content": { - "application/json": { + "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/ProductsListVm" + "type": "string", + "format": "binary" } } } From 134f0690627deec6138b4f6b95dfbd1e30ff8b21 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 14 Dec 2023 15:06:57 +1100 Subject: [PATCH 2/2] Frontend: Export as CSV for Products and Customers --- Src/WebUI/ClientApp/package-lock.json | 40 +++++++++++++++++++ Src/WebUI/ClientApp/package.json | 2 + .../app/customers/customers.component.html | 6 ++- .../src/app/customers/customers.component.ts | 29 +++++++++----- .../src/app/products/products.component.html | 5 ++- .../src/app/products/products.component.ts | 19 ++++++--- 6 files changed, 83 insertions(+), 18 deletions(-) diff --git a/Src/WebUI/ClientApp/package-lock.json b/Src/WebUI/ClientApp/package-lock.json index 788dfda0..ef0670a2 100644 --- a/Src/WebUI/ClientApp/package-lock.json +++ b/Src/WebUI/ClientApp/package-lock.json @@ -20,6 +20,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", @@ -32,6 +33,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", @@ -3271,6 +3273,16 @@ "node": ">=14" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@schematics/angular": { "version": "15.2.7", "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-15.2.7.tgz", @@ -3451,6 +3463,12 @@ "@types/send": "*" } }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true + }, "node_modules/@types/http-proxy": { "version": "1.17.11", "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.11.tgz", @@ -5854,6 +5872,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -14301,6 +14324,12 @@ "dev": true, "optional": true }, + "@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "peer": true + }, "@schematics/angular": { "version": "15.2.7", "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-15.2.7.tgz", @@ -14460,6 +14489,12 @@ "@types/send": "*" } }, + "@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true + }, "@types/http-proxy": { "version": "1.17.11", "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.11.tgz", @@ -16356,6 +16391,11 @@ "escape-string-regexp": "^1.0.5" } }, + "file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", diff --git a/Src/WebUI/ClientApp/package.json b/Src/WebUI/ClientApp/package.json index edaf8ffb..8023d294 100644 --- a/Src/WebUI/ClientApp/package.json +++ b/Src/WebUI/ClientApp/package.json @@ -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", @@ -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", diff --git a/Src/WebUI/ClientApp/src/app/customers/customers.component.html b/Src/WebUI/ClientApp/src/app/customers/customers.component.html index 412e344f..6664c0d8 100644 --- a/Src/WebUI/ClientApp/src/app/customers/customers.component.html +++ b/Src/WebUI/ClientApp/src/app/customers/customers.component.html @@ -2,8 +2,10 @@

Customers

- - +
diff --git a/Src/WebUI/ClientApp/src/app/customers/customers.component.ts b/Src/WebUI/ClientApp/src/app/customers/customers.component.ts index 46b6eac3..6ab1bb87 100644 --- a/Src/WebUI/ClientApp/src/app/customers/customers.component.ts +++ b/Src/WebUI/ClientApp/src/app/customers/customers.component.ts @@ -1,21 +1,23 @@ -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) { @@ -23,7 +25,14 @@ export class CustomersComponent { 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); + }); } } diff --git a/Src/WebUI/ClientApp/src/app/products/products.component.html b/Src/WebUI/ClientApp/src/app/products/products.component.html index e02e3b21..4aaccbde 100644 --- a/Src/WebUI/ClientApp/src/app/products/products.component.html +++ b/Src/WebUI/ClientApp/src/app/products/products.component.html @@ -2,7 +2,10 @@

Products

- Export +
diff --git a/Src/WebUI/ClientApp/src/app/products/products.component.ts b/Src/WebUI/ClientApp/src/app/products/products.component.ts index fec3cd2a..b82671b5 100644 --- a/Src/WebUI/ClientApp/src/app/products/products.component.ts +++ b/Src/WebUI/ClientApp/src/app/products/products.component.ts @@ -1,16 +1,25 @@ -import { Component } from '@angular/core'; +import { Component, inject, OnInit } from '@angular/core'; import { Client, ProductsListVm } from '../northwind-traders-api'; +import { saveAs } from 'file-saver'; @Component({ templateUrl: './products.component.html' }) -export class ProductsComponent { +export class ProductsComponent implements OnInit { + private client = inject(Client); productsListVm: ProductsListVm = new ProductsListVm(); - constructor(client: Client) { - client.getProductsList().subscribe(result => { + ngOnInit(): void { + this.client.getProductsList().subscribe(result => { this.productsListVm = result; - }, error => console.error(error)); + }); + } + + protected exportAsCsv() { + this.client.getProductsCsv().subscribe(result => { + const blob = new Blob([result.data], { type: result.headers.contentType }); + saveAs(blob, result.fileName); + }); } }