Quick Start to ASP.NET Core Web API and Blazor : Learn, Build, Deploy — Develop modern web apps using ASP.NET Core Web API, Minimal API, Identity, EF Core, and Blazor


Build Master-Detail Pages in ASP.NET Core MVC — Part 3

In the previous part of this series, we built the TeamsController to handle CRUD operations for the Teams table. In this continuation, we'll extend the application by introducing the TeamMembersController, which manages CRUD operations for the TeamMembers table.

Begin by adding a new class named TeamMembersController to the Controllers folder of your project. This controller will handle CRUD operations for the TeamMembers table, complementing the existing TeamsController. The following figure shows both the controllers in the Controllers folder.

To enable data access within the TeamMembersController, inject the AppDbContext just as you did in the TeamsController. This ensures the controller can interact with the TeamMembers table through Entity Framework Core.

public class TeamMembersController : Controller
{
    private readonly AppDbContext db;

    public TeamMembersController(AppDbContext db)
    {
        this.db = db;
    }
}

When you click the Manage Members button from a specific master row in the Teams grid, the corresponding team members are dynamically displayed in the detail grid below. This interaction creates a seamless master-detail experience, allowing users to view and manage members associated with each team without navigating away from the main interface. The following figure illustrates both grids in action—highlighting how selecting a team reveals its members in context.

Clicking the Manage Members button triggers the List() action in the TeamMembersController. The List() action is shown below:

[HttpPost]
public IActionResult List(int teamId)
{
    MasterDetailViewModel model = new MasterDetailViewModel
    {
        Teams = db.Teams.ToList(),
        SelectedTeam = db.Teams.Find(teamId),
        DataEntryTarget = DataEntryTargets.TeamMembers,
        DataDisplayMode = DataDisplayModes.Read
    };

    db.Entry(model.SelectedTeam).Collection
(team => team.Members).Load();

    return View("Main", model);
}

The List() action receives a teamId from the route parameter—typically triggered when a user clicks Manage Members for a specific team. Inside the action, we construct a MasterDetailViewModel object just as we did previously. However, this time we set DataEntryTarget to DataEntryTargets.TeamMembers. This signals that the detail grid should focus on CRUD operations for the TeamMembers table, rather than Teams.

To render the detail grid accurately, we need access to all TeamMember entities associated with the selected team. There are several ways to achieve this in Entity Framework Core—such as eager loading, lazy loading, or explicit loading. In this example, we use explicit loading via the Load() method. This approach ensures that the Members collection is populated before the view renders, allowing the detail table to display the correct data without additional round-trips.

When the Manage Member button is clicked from within the detail grid, the corresponding TeamMember row is visually highlighted. This helps users quickly identify which member is being edited or managed.

This happens in the Select() action as shown below:

[HttpPost]
public IActionResult Select(int teamId, int memberId)
{
    MasterDetailViewModel model = 
new MasterDetailViewModel
    {
        Teams = db.Teams.ToList(),
        SelectedTeam = db.Teams.Find(teamId),
        SelectedTeamMember = db.TeamMembers.Find(memberId),
        DataEntryTarget = DataEntryTargets.TeamMembers,
        DataDisplayMode = DataDisplayModes.Read
    };

    db.Entry(model.SelectedTeam).Collection
(team => team.Members).Load();

    return View("Main", model);
}

The Select() action receives both teamId and memberId from the route parameters. It constructs a fresh MasterDetailViewModel instance, setting the SelectedTeamMember to the member identified by memberId. Since this is a new request to the server, we explicitly load the related TeamMember entities using the Load() method to ensure the detail grid is properly populated.

When the Insert Member button is clicked, a data entry section appears just below the detail grid. This area allows users to input details for a new TeamMember, making the process of adding members seamless and intuitive.

Just like in TeamsController, the insert operation here is split across two actions: InsertEntry() and InsertSave(). This separation helps maintain clarity between rendering the data entry form and processing the submitted data.

The InsertEntry() action is simple and straightforward. It prepares the view model to display the data entry area for a new TeamMember, as shown below:

[HttpPost]
public IActionResult InsertEntry(int teamId)
{
    MasterDetailViewModel model = new 
MasterDetailViewModel
    {
        Teams = db.Teams.ToList(),
        SelectedTeam = db.Teams.Find(teamId),
        SelectedTeamMember = null,
        DataEntryTarget = DataEntryTargets.TeamMembers,
        DataDisplayMode = DataDisplayModes.Insert
    };
    db.Entry(model.SelectedTeam).Collection
(team => team.Members).Load();
    return View("Main", model);
}

Upon clicking the Save button, InsertSave() action is invoked. The InsertSave() action is shown next.

[HttpPost]
public IActionResult InsertSave(TeamMember member)
{
    db.TeamMembers.Add(member);
    db.SaveChanges();

    MasterDetailViewModel model = new 
MasterDetailViewModel
    {
        Teams = db.Teams.ToList(),
        SelectedTeam = db.Teams.Find(member.TeamID),
        SelectedTeamMember = db.TeamMembers.Find
(member.TeamMemberID),
        DataEntryTarget = DataEntryTargets.TeamMembers,
        DataDisplayMode = DataDisplayModes.Read
    };
    db.Entry(model.SelectedTeam).Collection
(team => team.Members).Load();
    return View("Main", model);
}

Here, we add the new TeamMember to the TeamMembers DbSet and then call SaveChanges. This works because our AppDbContext includes two DbSet properties: Teams and TeamMembers. However, there's an alternative way to achieve the same result. Let's explore that next.

Team t = db.Teams.Find(member.TeamID);
db.Entry(t).Collection
(team => team.Members).Load();
t.Members.Add(member);
db.SaveChanges();

In this case, we didn't use the TeamMembers DbSet at all. We first retrieve the Team to which the new member belongs. Then we add the new TeamMember to the Members collection—remember, Members is a navigation property—and call SaveChanges. EF Core automatically sets the TeamID foreign key and inserts the new TeamMember into the TeamMembers table.

When you click the Manage Member button in the detail grid, the corresponding TeamMember entry is displayed for editing:

Clicking the Edit, Delete, and Cancel buttons triggers the UpdateEntry(), Delete(), and CancelSelection() actions of the TeamMembersController. These actions handle the respective operations for the selected TeamMember entry and are discussed below.

The UpdateEntry() action switches the data entry mode to Update, allowing the selected TeamMember to be edited. The editing interface appears as shown below:

The TeamID and Team Member ID textboxes are marked as readonly because they are keys and can't be edited. The UpdateEntry() action responsible for this display is shown below:

[HttpPost]
public IActionResult UpdateEntry(int teamId, 
int memberId)
{
    MasterDetailViewModel model = 
new MasterDetailViewModel
    {
        Teams = db.Teams.ToList(),
        SelectedTeam = db.Teams.Find(teamId),
        SelectedTeamMember = db.TeamMembers.Find(memberId),
        DataEntryTarget = DataEntryTargets.TeamMembers,
        DataDisplayMode = DataDisplayModes.Update
    };
    db.Entry(model.SelectedTeam).Collection
(team => team.Members).Load();
    return View("Main", model);
}

As you can see, we set the DataDisplayMode to Update.

Clicking on the Save button triggers the UpdateSave() action:

[HttpPost]
public IActionResult UpdateSave(TeamMember member)
{
    db.TeamMembers.Update(member);
    db.SaveChanges();

    MasterDetailViewModel model = 
new MasterDetailViewModel
    {
        Teams = db.Teams.ToList(),
        SelectedTeam = db.Teams.Find(member.TeamID),
        SelectedTeamMember = db.TeamMembers.Find
(member.TeamMemberID),
        DataEntryTarget = DataEntryTargets.TeamMembers,
        DataDisplayMode = DataDisplayModes.Read
    };

    db.Entry(model.SelectedTeam).Collection
(team => team.Members).Load();

    return View("Main", model);
}

Clicking on the Cancel button in the Update mode triggers the CancelEntry() action.

[HttpPost]
public IActionResult CancelEntry(int teamId)
{
    MasterDetailViewModel model = new 
MasterDetailViewModel
    {
        Teams = db.Teams.ToList(),
        SelectedTeam = db.Teams.Find(teamId),
        SelectedTeamMember = null,
        DataEntryTarget = DataEntryTargets.
TeamMembers,
        DataDisplayMode = DataDisplayModes.Read
    };

    db.Entry(model.SelectedTeam).Collection
(team => team.Members).Load();

    return View("Main", model);
}

If you click on the Cancel button when a TeamMember is shown in Read mode, the CancelSelection() action is invoked. This action clears the selection of the TeamMember row and hides the TeamMember entry area from the view.

[HttpPost]
public IActionResult CancelSelection(int teamId)
{
    MasterDetailViewModel model = new 
MasterDetailViewModel
    {
        Teams = db.Teams.ToList(),
        SelectedTeam = db.Teams.Find(teamId),
        SelectedTeamMember = null,
        DataEntryTarget = DataEntryTargets.
TeamMembers,
        DataDisplayMode = DataDisplayModes.Read
    };

    db.Entry(model.SelectedTeam).Collection
(team => team.Members).Load();

    return View("Main", model);
}

Finally, clicking the Delete button triggers the Delete() action as shown below:

[HttpPost]
public IActionResult Delete(int teamId, 
int memberId)
{
    TeamMember member = db.TeamMembers.
Find(memberId);
    db.TeamMembers.Remove(member);
    db.SaveChanges();

    MasterDetailViewModel model = new 
MasterDetailViewModel
    {
        Teams = db.Teams.ToList(),
        SelectedTeam = db.Teams.Find(teamId),
        SelectedTeamMember = null,
        DataEntryTarget = DataEntryTargets.
TeamMembers,
        DataDisplayMode = DataDisplayModes.Read
    };

    db.Entry(model.SelectedTeam).Collection
(team => team.Members).Load();

    return View("Main", model);
}

In this approach, you first locate the TeamMember to be deleted, remove it from the TeamMembers DbSet, and then call SaveChanges. Once the deletion is complete, the SelectedTeamMember property is set to null, as the corresponding record no longer exists.

Alternatively—just as with insertion—you can delete a TeamMember using the parent Team's Members collection. This method leverages the entity relationship, and calling SaveChanges will persist the change accordingly. The alternate technique is illustrated below:

 
Team t = db.Teams.Find(teamId);
db.Entry(t).Collection(team => 
team.Members).Load();
TeamMember tm = t.Members.Find
(i => i.TeamMemberID == memberId);
t.Members.Remove(tm);
db.SaveChanges();

In the above code, we locate the TeamMember within the Members collection and remove it using the Remove method. A call to SaveChanges then commits the deletion to the database.

With this, all actions within the TeamMembersController are complete. In the next part of this series, we'll turn our attention to the views and partials that shape the user interface of the application—bringing structure and interactivity to life.

That's all for now—may your keystrokes be mindful, and your work a quiet offering. With this heartfelt wish I bring my pen to a gentle close.


Author : Bipin Joshi
Bipin Joshi is an independent software consultant and trainer, specializing in Microsoft web development technologies. Having embraced the yogic way of life, he also mentors select individuals in Ajapa Gayatri and allied meditative practices. Blending the disciplines of code and consciousness, he has been meditating, programming, writing, and teaching for over 30 years. As a prolific author, he shares his insights on both software development and yogic wisdom through his websites.

Posted On : 01 September 2025