Part 2/3 A Full-Stack Expense Tracking App With Blazor, Web API & EF Core and SQL Server Express.
Working Frontend For Expense Tracking App.
Table of contents
In Part 1 we built a working API that could create, read, update and delete expenses. Part 2 would be moving on from there by creating services to make our code better and also working on the front end.
NOTE: This tutorial uses .NET 6.0 (Long Term Support)
and may not work the same for other versions.
Add Services In Server Project
Using best practices we should not have "fat controllers". The controller is supposed to deal only with the HTTP requests while other things are moved to services.
Create Services Folders And files
Right-Click On
BlazorExpenseTracker.Server
-> Add -> New FolderName The Folder
Services
.In the Services folder, we create another folder named
ExpenseService
.Right-Click On
ExpenseService
folder -> Add -> Class -> Select InterfaceName the Interface
IExpenseService.cs
namespace BlazorExpenseTracker.Server.Services.ExpenseService
{
public interface IExpenseService
{
}
}
Right-Click On
ExpenseService
folder -> Add -> ClassName the Class
ExpenseService.cs
Let
ExpenseService.cs
inherit fromIExpenseService.cs
namespace BlazorExpenseTracker.Server.Services.ExpenseService
{
public class ExpenseService: IExpenseService
{
}
}
Register ExpenseService.
In BlazorExpenseTracker.Server/Program.cs
using BlazorExpenseTracker.Server.Data;
using BlazorExpenseTracker.Server.Services.ExpenseService;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();
builder.Services.AddDbContext<DataContext>(options =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
});
builder.Services.AddSwaggerGen(option =>
{
option.SwaggerDoc("v1", new OpenApiInfo { Title = "BlazorExpenseTracker API", Version = "v1" });
});
// Register ExpenseService
builder.Services.AddScoped<IExpenseService, ExpenseService>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
app.UseSwagger();
app.UseSwaggerUI();
}
else
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
app.UseRouting();
app.MapRazorPages();
app.MapControllers();
app.MapFallbackToFile("index.html");
app.Run();
Add IExpenseService Code
In IExpenseService.cs
using BlazorExpenseTracker.Shared.Models;
using System.Collections.Generic;
namespace BlazorExpenseTracker.Server.Services.ExpenseService
{
public interface IExpenseService
{
Task<List<ExpenseModel>> GetExpenseAsync();
Task<ExpenseModel> CreateExpenseAsync(ExpenseModel expense);
Task<ExpenseModel> EditExpenseAsync(ExpenseModel expense, int id);
Task RemoveExpense(int id);
}
}
In ExpenseService.cs
add a constructor and implement the interface. Finaly add the code using _context in each method.
NOTE: Add async
to every method
using BlazorExpenseTracker.Server.Data;
using BlazorExpenseTracker.Shared.Models;
using Microsoft.EntityFrameworkCore;
namespace BlazorExpenseTracker.Server.Services.ExpenseService
{
public class ExpenseService : IExpenseService
{
private readonly DataContext _context;
public ExpenseService(DataContext context)
{
_context = context;
}
public async Task<ExpenseModel> CreateExpenseAsync(ExpenseModel expense)
{
var response = await _context.Expenses.AddAsync(expense);
await _context.SaveChangesAsync();
return response.Entity;
}
public async Task<ExpenseModel> EditExpenseAsync(ExpenseModel expense, int id)
{
ExpenseModel response = null;
var DbExpense = await _context.Expenses.FirstOrDefaultAsync(e => e.Id == id);
if (DbExpense != null)
{
DbExpense.Amount = expense.Amount;
DbExpense.Title = expense.Title;
DbExpense.Description = expense.Description;
DbExpense.CreatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
response= DbExpense;
}
return response;
}
public async Task<List<ExpenseModel>> GetExpenseAsync()
{
var response = await _context.Expenses.ToListAsync();
return response;
}
public async Task RemoveExpense(int id)
{
var DbExpense = await _context.Expenses.FirstOrDefaultAsync(e => e.Id == id);
if (DbExpense != null)
{
_context.Expenses.Remove(DbExpense);
}
await _context.SaveChangesAsync();
}
}
}
Update ExpenseController
In ExpenseController.cs
use the services created.
- Remove context and Inject ExpenseService.
using BlazorExpenseTracker.Server.Data;
using BlazorExpenseTracker.Server.Services.ExpenseService;
using BlazorExpenseTracker.Shared.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace BlazorExpenseTracker.Server.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class ExpensesController : ControllerBase
{
private readonly IExpenseService _expenseService;
public ExpensesController(IExpenseService expenseService )
{
_expenseService = expenseService;
}
[HttpGet]
public async Task<ActionResult<List<ExpenseModel>>> GetAllExpensesAsync()
{
List<ExpenseModel> response = await _expenseService.GetExpenseAsync();
return Ok(response);
}
[HttpPost]
public async Task<ActionResult<ExpenseModel>> CreateExpenseAsync(ExpenseModel expense)
{
ExpenseModel response = await _expenseService.CreateExpenseAsync(expense);
return Ok(response);
}
[HttpPut]
[Route("{id}")]
public async Task<ActionResult<ExpenseModel>> EditExpenseAsync(ExpenseModel expense, int id)
{
ExpenseModel response = await _expenseService.EditExpenseAsync(expense, id);
return Ok(response);
}
[HttpDelete]
[Route("{id}")]
public async Task RemoveExpense(int id)
{
await _expenseService.RemoveExpense(id);
}
}
}
- Run the app and Test the API End Points.
Add Mock Code To Index.razor
First, we would add some placeholder code to BlazorExpenseTracker.Client/Pages/Index.razor
to act as mock code to help us see how the page would look.
In BlazorExpenseTracker.Client/Pages/Index.razor
@page "/"
<PageTitle>Home</PageTitle>
<div style="display:flex; flex-direction:column; justify-content:center">
<div style="display:flex; justify-content:end">
<a href="#">
<svg style="width:50px; cursor:pointer " class="svg-icon" viewBox="0 0 20 20">
<path d="M14.613,10c0,0.23-0.188,0.419-0.419,0.419H10.42v3.774c0,0.23-0.189,0.42-0.42,0.42s-0.419-0.189-0.419-0.42v-3.774H5.806c-0.23,0-0.419-0.189-0.419-0.419s0.189-0.419,0.419-0.419h3.775V5.806c0-0.23,0.189-0.419,0.419-0.419s0.42,0.189,0.42,0.419v3.775h3.774C14.425,9.581,14.613,9.77,14.613,10 M17.969,10c0,4.401-3.567,7.969-7.969,7.969c-4.402,0-7.969-3.567-7.969-7.969c0-4.402,3.567-7.969,7.969-7.969C14.401,2.031,17.969,5.598,17.969,10 M17.13,10c0-3.932-3.198-7.13-7.13-7.13S2.87,6.068,2.87,10c0,3.933,3.198,7.13,7.13,7.13S17.13,13.933,17.13,10"></path>
</svg>
</a>
</div>
<div style="display:flex; flex-direction:column; text-align:center">
<p style="color:gray">Total Expenses</p>
<h2 style="font-weight:600; margin-top:-10px">$100.00</h2>
</div>
<div style="display:flex; flex-direction:column; padding-top:64px">
<div style=" border-bottom: 2px solid lightgray; margin-bottom:12px; ">
<div style="display:flex; flex-direction: row; align-items:center; ">
<div style="display:flex; flex-direction: row; width:100%; cursor:pointer ; align-items:center; ">
<div style="height:64px; width: 100%;">
<p style="font-weight:600">Eat Out</p>
<p style="color:gray">Mon 21 Dec 2023 3:30pm</p>
</div>
<div style="padding-right:12px">
$100
</div>
<div style="padding:16px;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pencil" viewBox="0 0 16 16"> <path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" /> </svg>
</div>
<div style=" padding:16px; background-color:antiquewhite; cursor:pointer ;">
<svg style="width:18px" class="svg-icon" viewBox="0 0 20 20">
<path d="M7.083,8.25H5.917v7h1.167V8.25z M18.75,3h-5.834V1.25c0-0.323-0.262-0.583-0.582-0.583H7.667
c-0.322,0-0.583,0.261-0.583,0.583V3H1.25C0.928,3,0.667,3.261,0.667,3.583c0,0.323,0.261,0.583,0.583,0.583h1.167v14
c0,0.644,0.522,1.166,1.167,1.166h12.833c0.645,0,1.168-0.522,1.168-1.166v-14h1.166c0.322,0,0.584-0.261,0.584-0.583
C19.334,3.261,19.072,3,18.75,3z M8.25,1.833h3.5V3h-3.5V1.833z M16.416,17.584c0,0.322-0.262,0.583-0.582,0.583H4.167
c-0.322,0-0.583-0.261-0.583-0.583V4.167h12.833V17.584z M14.084,8.25h-1.168v7h1.168V8.25z M10.583,7.083H9.417v8.167h1.167V7.083
z"></path>
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
@code {
}
Add Services In Client Project
Create Services Folders And files
Right-Click On
BlazorExpenseTracker.Client
-> Add -> New FolderName The Folder
Services
.In the Services folder, we create another folder named
ExpenseService
.Right-Click On
ExpenseService
folder -> Add -> Class -> Select Interface
namespace BlazorExpenseTracker.Client.Services
{
public interface IExpenseService
{
}
}
Right-Click On
ExpenseService
folder -> Add -> ClassName the Class
ExpenseService.cs
Let
ExpenseService.cs
inherit fromIExpenseService.cs
namespace BlazorExpenseTracker.Client.Services
{
public class ExpenseService : IExpenseService
{
}
}
Add IExpenseService Code
In IExpenseService.cs
using BlazorExpenseTracker.Shared.Models;
namespace BlazorExpenseTracker.Client.Services.ExpenseService
{
public interface IExpenseService
{
List<ExpenseModel> Expenses { get; set; }
decimal TotalExpenses { get; set; }
Task<List<ExpenseModel>> GetExpensesAsync();
Task<ExpenseModel> CreateExpenseAsync(ExpenseModel expense);
Task<ExpenseModel> EditExpenseAsync(ExpenseModel expense, int id);
Task RemoveExpense(int id);
}
}
In ExpenseService.cs
add a constructor and implement the interface.
Add the bodies for each method as shown below.
NOTE: Add async
to every method
using BlazorExpenseTracker.Shared.Models;
using System.Net.Http.Json;
namespace BlazorExpenseTracker.Client.Services.ExpenseService
{
public class ExpenseService : IExpenseService
{
private readonly HttpClient _http;
public ExpenseService(HttpClient http)
{
_http = http;
}
public List<ExpenseModel> Expenses { get; set; } = new List<ExpenseModel>();
public decimal TotalExpenses { get; set; } = 0.0M;
public async Task<ExpenseModel> CreateExpenseAsync(ExpenseModel expense)
{
var response = await _http.PostAsJsonAsync<ExpenseModel>("/api/Expenses", expense);
return await response.Content.ReadFromJsonAsync<ExpenseModel>();
}
public async Task<ExpenseModel> EditExpenseAsync(ExpenseModel expense, int id)
{
var response = await _http.PutAsJsonAsync<ExpenseModel>($"/api/Expenses/{id}", expense);
return await response.Content.ReadFromJsonAsync<ExpenseModel>();
}
public async Task<List<ExpenseModel>> GetExpensesAsync()
{
var response = await _http.GetFromJsonAsync<List<ExpenseModel>>("/api/Expenses");
if (response != null)
{
Expenses = response;
CalculateTotalExpenses();
}
return Expenses;
}
public async Task RemoveExpense(int id)
{
await _http.DeleteAsync($"/api/Expenses/{id}");
}
private void CalculateTotalExpenses()
{
TotalExpenses = 0;
foreach (var expense in Expenses)
{
TotalExpenses += expense.Amount;
}
}
}
}
Show List Of Transactions In Index.razor
Register IExpenseService
- In
BlazorExpenseTracker.Client/Program.cs
and addglobal using BlazorExpenseTracker.Client.Service.ExpenseService;
// Register IExpenseService - global using statement
global using BlazorExpenseTracker.Client.Services.ExpenseService;
using BlazorExpenseTracker.Client;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
// Register IExpenseService
builder.Services.AddScoped<IExpenseService, ExpenseService>();
await builder.Build().RunAsync();
Show Data In Index.razor
In Index.razor
@page "/"
@inject IExpenseService ExpenseService
<PageTitle>Home</PageTitle>
<div style="display:flex; flex-direction:column; justify-content:center">
<div style="display:flex; justify-content:end">
<a href="#">
<svg style="width:50px; cursor:pointer " class="svg-icon" viewBox="0 0 20 20">
<path d="M14.613,10c0,0.23-0.188,0.419-0.419,0.419H10.42v3.774c0,0.23-0.189,0.42-0.42,0.42s-0.419-0.189-0.419-0.42v-3.774H5.806c-0.23,0-0.419-0.189-0.419-0.419s0.189-0.419,0.419-0.419h3.775V5.806c0-0.23,0.189-0.419,0.419-0.419s0.42,0.189,0.42,0.419v3.775h3.774C14.425,9.581,14.613,9.77,14.613,10 M17.969,10c0,4.401-3.567,7.969-7.969,7.969c-4.402,0-7.969-3.567-7.969-7.969c0-4.402,3.567-7.969,7.969-7.969C14.401,2.031,17.969,5.598,17.969,10 M17.13,10c0-3.932-3.198-7.13-7.13-7.13S2.87,6.068,2.87,10c0,3.933,3.198,7.13,7.13,7.13S17.13,13.933,17.13,10"></path>
</svg>
</a>
</div>
<div style="display:flex; flex-direction:column; text-align:center">
<p style="color:gray">Total Expenses</p>
<h2 style="font-weight:600; margin-top:-10px">$@Decimal.Round(@ExpenseService.TotalExpenses, 2)</h2>
</div>
<div style="display:flex; flex-direction:column; padding-top:64px">
@foreach (var expense in @ExpenseService.Expenses)
{
<div style=" border-bottom: 2px solid lightgray; margin-bottom:12px; ">
<div style="display:flex; flex-direction: row; align-items:center; ">
<div style="display:flex; flex-direction: row; width:100%; cursor:pointer ; align-items:center; ">
<div style="height:64px; width: 100%;">
<p style="font-weight:600">@expense.Title</p>
<p style="color:gray">@expense.CreatedAt.ToString("dd MMM yyyy hh:mm tt")</p>
</div>
<div style="padding-right:12px">
$@Decimal.Round(@expense.Amount, 2)
</div>
<div style="padding:16px;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pencil" viewBox="0 0 16 16"> <path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" /> </svg>
</div>
<div style=" padding:16px; background-color:antiquewhite; cursor:pointer ;">
<svg style="width:18px" class="svg-icon" viewBox="0 0 20 20">
<path d="M7.083,8.25H5.917v7h1.167V8.25z M18.75,3h-5.834V1.25c0-0.323-0.262-0.583-0.582-0.583H7.667
c-0.322,0-0.583,0.261-0.583,0.583V3H1.25C0.928,3,0.667,3.261,0.667,3.583c0,0.323,0.261,0.583,0.583,0.583h1.167v14
c0,0.644,0.522,1.166,1.167,1.166h12.833c0.645,0,1.168-0.522,1.168-1.166v-14h1.166c0.322,0,0.584-0.261,0.584-0.583
C19.334,3.261,19.072,3,18.75,3z M8.25,1.833h3.5V3h-3.5V1.833z M16.416,17.584c0,0.322-0.262,0.583-0.582,0.583H4.167
c-0.322,0-0.583-0.261-0.583-0.583V4.167h12.833V17.584z M14.084,8.25h-1.168v7h1.168V8.25z M10.583,7.083H9.417v8.167h1.167V7.083
z"></path>
</svg>
</div>
</div>
</div>
</div>
}
</div>
</div>
@code {
protected override async Task OnInitializedAsync()
{
await ExpenseService.GetExpensesAsync();
}
}