Creating a to-do app using Blazor - Part 1
Table of contents
Planning
Let's draft a plan for what the final result should look like and what the application will support. In this case, we'll do a simple To-do application in which the main functionalities are the following:
- Create a to-do entry
- Edit a to-do entry
- Remove a to-do entry
- Render them in a table-like style
- In this article, I'll use an "in-memory" database which will be simply a variable containing the to-do entries.
- The data models are prepared to be used with a persistent "disk-storage" database.
How how the blazor site should look like:
We chose a simple UI to render the To-do entries, and each entry has three main buttons: Edit, Remove, and View entry. When the Edit button is clicked, the To-do entry replaces the content visualization elements with inputs that allow the user to easily edit the entry. At the same time, a button that allows saving the entry data is also visible. Let's dig into it!
Creating the project
The following snippet presents the commands necessary to create the project using the dotnet CLI
. You can also create the solution using Visual Studio or Rider.
# Create solution for the project
dotnet new sln -n BlazorTodoApp --output BlazorTodoApp
# Create the Blazor project
dotnet new blazorserver --no-https -o BlazorTodoApp/BlazorTodoApp
cd BlazorTodoApp
# Add to the solution
dotnet sln add BlazorTodoApp
Open the solution in your IDE of choice and remove the Pages/Counter.razor
, Pages/FetchData.razor
, and Shared/SurveyPrompt.razor
pages. Delete the WeatherForecast.cs
and WeatherForecastService.cs
files, as we will not need them. Remove the line builder.Services.AddSingleton<WeatherForecastService>();
from Program.cs
. Comment out the line using BlazorTodoApp.Data;
since we will use it later.
Change the Pages/Index.razor
file to have only the following:
@page "/"
<PageTitle>Todo App</PageTitle>
Finally, in the Shared/NavMenu.razor
, let's remove links to the pages we just removed, and keep only the "Home" link which we'll use to implement the To-do list. The "NavMenu.razor" file should look like the following:
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">BlazorTodoApp</a>
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
</div>
</div>
<div class="@NavMenuCssClass nav-scrollable" @onclick="ToggleNavMenu">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="oi oi-home" aria-hidden="true"></span> Home
</NavLink>
</div>
</nav>
</div>
@code {
private bool collapseNavMenu = true;
private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
}
HTML template
Let's begin writing an HTML template for the To-do app. In the Index.razor
file, we have all the necessary HTML that will allow us to further develop the To-do app. For now, it's just static code with no functionality. We'll turn this content dynamic after we create the data models and the TodoListService
, which is responsible for storing methods to manipulate the to-do entries database.
This is how the Index.razor
looks like with the HTML template:
@page "/"
<PageTitle>Todo App</PageTitle>
<div class="navbar-nav align-items-center m-3">
<div class="col">
<button type="button" class=" btn btn-primary px-sm-4 m-1 align-content-center">Add item</button>
</div>
</div>
<div id="accordion">
<div class="card">
<div class="card-header ">
<div class="row ">
<div class="col-8 ">
<h5 class="mb-0">
<button class="btn btn-link">Do Blazor App</button>
</h5>
</div>
<div class="col-1">
<button class="btn btn-link">
<span class="oi oi-pencil"></span>
</button>
</div>
<div class="col-1">
<button class="btn btn-link">
<span class="oi oi-x"></span>
</button>
</div>
<div class="col-1">
<button class="btn btn-link">
<span class="oi oi-eye"></span>
</button>
</div>
</div>
</div>
<div id="collapseOne" class="collapse show " aria-labelledby="headingOne" data-parent="#accordion">
<div class="card-body">Dummy content</div>
</div>
</div>
<div class="card">
<div class="card-header ">
<div class="row ">
<div class="col-8 ">
<input type="text" placeholder="Title" class="form-control" _bl_10f627d4-53ff-4296-9ef8-a37e888bee25="">
</div>
<div class="col-1">
<button class="btn btn-link">
<span class="oi oi-pencil"></span>
</button>
</div>
<div class="col-1">
<button class="btn btn-link">
<span class="oi oi-collapse-down"></span>
</button>
</div>
</div>
</div>
<div class="collapse show " aria-labelledby="headingOne" data-parent="#accordion">
<div class="card-body">
<textarea id="exampleFormControlTextarea1" rows="3" class="form-control" _bl_729f9d20-e112-48a4-963d-5b74e32eae12=""></textarea>
</div>
</div>
</div>
</div>
Blazor components
It's a good idea to refactor the code we have in Index.razor into two main components:
- Todo Entry: Will have all the rendering code and logic responsible for the state of a to-do entry.
- Todo List: Will have all the rendering code and logic responsible for rendering a list of to-do entries. It's from there that we receive all the entries stored in the database and iterate through each of them, rendering each of them using the Todo Entry Razor component.
For this, we will create a TodoEntry.razor and a TodoList.razor in the Shared folder. Let's move those to Shared/TodoList.razor and move one element with the class "card" to the "TodoEntry.razor".
This is how the Shared/TodoEntry.razor looks like now:
<div class="card">
<div class="card-header ">
<div class="row ">
<div class="col-8 ">
<h5 class="mb-0">
<button class="btn btn-link">Do Blazor App</button>
</h5>
</div>
<div class="col-1">
<button class="btn btn-link">
<span class="oi oi-pencil"></span>
</button>
</div>
<div class="col-1">
<button class="btn btn-link">
<span class="oi oi-x"></span>
</button>
</div>
<div class="col-1">
<button class="btn btn-link">
<span class="oi oi-eye"></span>
</button>
</div>
</div>
</div>
<div id="collapseOne" class="collapse show " aria-labelledby="headingOne" data-parent="#accordion">
<div class="card-body">Dummy content</div>
</div>
</div>
@code {
}
And the Shared/TodoList.razor:
<div class="navbar-nav align-items-center m-3">
<div class="col">
<button type="button" class=" btn btn-primary px-sm-4 m-1 align-content-center">Add item</button>
</div>
</div>
<div id="accordion">
@* Render each todo-entry here *@
</div>
@code {
}
Data models
Entry data model
The entry data model is the model with the properties used to persist the data in the database. We'll use Title and Content properties. The EntryData.cs
in the namespace BlazorTodoApp.Data
should look like this:
namespace BlazorTodoApp.Data;
public class EntryData
{
public int Id { get; set; }
public string Title { get; set; }
public string Content { get; set; }
}
The Id
is a unique identifier, the Title is the title of the to-do entry, and the content is the respective text content. In the next post, we'll improve this to use markdown, and to render the respective markdown.
Rendering Model
The rendering model tries to answer what can possibly happen in the rendering state of a To-do entry, such as when the user clicks to edit an entry, the entry should render inputs instead of the content. It should also store if the entry is collapsed or not. It can define a temporary object that's being rendered but not added to the database. Finally, we also need to include the respective to-do entry data in this object.
Let's create a EntryRenderData.cs
in the Data folder:
namespace BlazorTodoApp.Data;
public class EntryRenderData
{
public bool IsCollapsed { get; set; } = true;
public bool IsEditMode { get; set; } = false;
public bool IsCreateMode { get; set; } = false;
public EntryData Data { get; set; }
public EntryRenderData(EntryData data)
{
Data = data;
}
}
This class contains the following properties:
IsCollapsed
: Defines the collapsed state of the entry in the list.IsEditMode
: Defines if the entry is in edit mode so that we can render the inputs instead of the contents, the user can toggle the "edit" button to activate/deactivate this state.IsCreateMode
: If we create a new entry to the list and the object isn't yet in the database, we use this variable to hide the Edit button, since we won't edit an entry that's not yet created.Data
: The actual data that should be rendered.
Next, we'll create a service that has methods to create, update, and delete to-do entries (usually shortened as CRUD operations).
To-do list service
In this example, we'll use a singleton service (like the previous deleted WeatherForecastService) so that we have the data stored in it in a variable called TodoEntries
, this is a list storing TodoEntryData
s and will persist in memory while the application is running.
We'll initialize the List with some dummy data. If you prefer to use a persistent storage like MySQL, Microsoft SQL, or PostgreSQL, you can use the EntryData model as the main database model. For simplicity, we'll store them in-memory.
For now, let's create a TodoListService.cs in the Data folder, with the following operations:
- Get entries
- Get one entry by id
- Delete entry
We'll add the create and update operations later.
namespace BlazorTodoApp.Data;
public class TodoListService
{
public List<EntryData> TodoEntries { get; set; }
public TodoListService()
{
TodoEntries = new List<EntryData>()
{
new EntryData
{
Id = 0,
Content = "Dummy content",
Title = "Do Blazor App"
},
new EntryData
{
Id = 1,
Content = "Yet another dummy content",
Title = "Do a code recipe"
},
};
}
/// <summary>
/// Gets To-Do entries
/// </summary>
/// <returns>Array containing the entries</returns>
public List<EntryData> GetEntries()
{
// Load from the database
return TodoEntries.ToList();
}
public EntryData? GetEntryById(int id)
{
return TodoEntries.FirstOrDefault(x => x.Id == id);
}
public bool DeleteEntry(int entryId)
{
var index = TodoEntries.FindIndex(x => x.Id == entryId);
if (index != -1)
{
TodoEntries.RemoveAt(index);
return true;
}
return false;
}
}
Finally, we need to register the service. We can do that in the Program.cs
file. Below builder.Services.AddServerSideBlazor();
add the following:
builder.Services.AddSingleton<TodoListService>();
At the top of the file, uncomment or add the using statement using BlazorTodoApp.Data;
. That's it, we have now the service ready with some dummy data.
Rendering entries
Now we can start rendering the entries. For this, we need to do some changes to the TodoEntry.razor, and implement some parameters such as EditMode
and Collapsed
. The idea is when we click on the title of the to-do item or in the eye icon, the item shows its contents or hides them. Let's do this logic in the TodoEntry.razor:
<div class="card">
<div class="card-header ">
<div class="row ">
<div class="col-8 ">
@if (EditMode)
{
<InputText type="text" class="form-control" placeholder="Title" @bind-Value="@Title"/>
}
else
{
<h5 class="mb-0">
<button class="btn btn-link">
@Title
</button>
</h5>
}
</div>
@if (EditMode)
{
// Show the save button
<div class="col-1">
<button class="btn btn-link">
<span class="oi oi-collapse-down" aria-hidden="true"></span>
</button>
</div>
}
else
{
<div class="col-1">
<button class="btn btn-link">
<span class="oi oi-x" aria-hidden="true"></span>
</button>
</div>
<div class="col-1">
<button class="btn btn-link">
<span class="oi oi-eye" aria-hidden="true"></span>
</button>
</div>
}
</div>
</div>
<div id="collapseOne" class="collapse show" aria-labelledby="headingOne" data-parent="#accordion">
<div class="card-body">
@if (EditMode)
{
<InputTextArea class="form-control" id="exampleFormControlTextarea1" rows="3" @bind-Value="@Content"/>
}
else
{
@Content
}
</div>
</div>
</div>
@code
{
[Parameter]
public string Title { get; set; }
[Parameter]
public string Content { get; set; }
[Parameter]
public bool EditMode { get; set; }
}
Our TodoEntry blazor component should use parameters to receive data and event callbacks for handling different actions such as clicking on the title, editing, etc. These parameters allow the parent component to set the values of the component's properties and handle events that are triggered by the component. The Title and Content properties allow us to render the dummy data in the TodoList.razor.
First, make sure we include the TodoList component in the Index.razor page:
@page "/"
<PageTitle>Todo App</PageTitle>
<TodoList/>
Now we can work on rendering the items in the TodoList.razor. For that, we need to take the following steps:
- Inject the TodoListService into the TodoList.razor by placing
@inject TodoListService ListService
at the top of the file. - Declare a Dictionary property that takes an
int
as key (being the Id of the entry) and the value is anEntryRenderData
. This will allow us to change rendering data efficiently since we don't need to do lookups for indices like we would need to do if using a List. - Override the
OnInitialized
method to load the data from the service and fill the Dictionary values. - In the HTML part, use a
@foreach
loop to render eachTodoEntry
. For this, we iterate through the Dictionary Values and finally assign the properties we defined in the TodoEntry.razor with the necessary data.
With those changes, TodoList.razor looks as follows:
@using BlazorTodoApp.Data
@inject TodoListService ListService
<div class="navbar-nav align-items-center m-3">
<div class="col">
<button type="button"
class=" btn btn-primary px-sm-4 m-1 align-content-center">
Add item
</button>
</div>
</div>
<div id="accordion">
@foreach (var todoEntry in RenderData.Values)
{
<TodoEntry
Title=@todoEntry.Data.Title
Content=@todoEntry.Data.Content/>
}
</div>
@code
{
Dictionary<int, EntryRenderData> RenderData { get; set; } = new();
protected override void OnInitialized()
{
// Initialize the rendering data
foreach (var entryData in ListService.GetEntries())
{
RenderData.Add(entryData.Id, new EntryRenderData(entryData));
}
}
}
That's it, we can now render the to-do entries data fetched from the service. In the next section, we'll handle the collapse state and introduce EventCallbacks
which allows communicating DOM events from the child to the parent.
Collapsing elements
We want to collapse/expand to-do entries in two ways:
- When the user clicks on the To-do title
- When the user clicks on the eye icon.
Firstly, we need to change the TodoEntry.razor file to store the collapsed state and a callback for when such clicks occur. For this, we add the following to the @code
block:
[Parameter]
public bool Collapsed { get; set; }
[Parameter]
public EventCallback OnEntryClicked { get; set; }
We declare those public properties with the [Parameter]
attribute so that we can easily define the state from a parent component (TodoList.razor). Next, let's change the HTML part to handle the collapsing/expanding depending on the value of Collapsed
. In the element responsible for rendering the @Content
, we can use a ternary operator to add the bootstrap class show
when Collapsed
is false:
<div id="collapseOne" class="collapse @(Collapsed ? "" : "show")">
<div class="card-body">
<!-- Entry content body -->
</div>
Our parent component functions as a controller of the children entries, so we need to do two things:
- Assign the callback to a method that will toggle the
Collapsed
state of the to-do entry. - Assign the parameterized
Collapsed
state. - Handle the callback in the To-do list component.
To assign the click callback we will use @onclick
in the TodoEntry.razor
, when clicking on the "eye" icon:
<button class="btn btn-link" @onclick="OnEntryClicked">
<span class="oi oi-eye" aria-hidden="true"></span>
</button>
And in the title button:
<h5 class="mb-0">
<button class="btn btn-link" @onclick="OnEntryClicked">
@Title
</button>
</h5>
We already have a IsCollapsed
property in the EntryRenderData.cs
which we'll use to manipulate this state. Let's define the Collapsed
parameter (that we just defined in the Entry Blazor component) in the TodoList.razor
that's rendering all the entries:
<TodoEntry
Title=@todoEntry.Data.Title
Content=@todoEntry.Data.Content
Collapsed=@todoEntry.IsCollapsed
/>
Next let's define the behavior of OnEntryClicked
callback in the TodoList.razor:
async Task OnEntryClicked(EntryRenderData entryData)
{
var collapsed = RenderData[entryData.Data.Id].IsCollapsed;
RenderData[entryData.Data.Id].IsCollapsed = !collapsed;
}
The method OnEntryClicked is an asynchronous method that takes in an EntryRenderData object as a parameter. It first retrieves the current collapsed state of the entry by accessing the IsCollapsed property of the RenderData object that corresponds to the entry's ID. Then, it sets the IsCollapsed
property of the same render data object to the opposite of the current collapsed state, effectively toggling the collapsed state of the entry.
When the user clicks on a to-do entry, it will toggle the collapse state of the entry, which means if it's expanded it will collapse it and vice versa. Finally lets assign the callback parameter to this method we just created:
@foreach (var todoEntry in RenderData.Values)
{
<TodoEntry
OnEntryClicked="async () => await OnEntryClicked(todoEntry)"
/>
}
That's it, we can now expand and collapse to-do entries in the To-do list parent component. We can extend this functionality to, for example, add a button to expand all entries or to collapse all entries easily with the parent-child relationship.
Sample Code
You can access the full code used in this series here: Github - BlazorTodoApp (Rendering)
Next steps
In the second part of this series, the focus will be on handling the functionality of editing, creating, and removing to-do entries.