Build Master-Detail Pages in ASP.NET Core MVC — Part 2
In the previous part of this article series, we introduced the sample application and built the EF Core model, which includes the Team, TeamMember, and AppDbContext classes. In this installment, we'll extend the web application by adding a TeamsController to perform CRUD operations on the Teams table.
To begin, open the same project from the previous part and add two enumerations to the Models folder: DataDisplayModes and DataEntryTargets. These enums are defined as follows:
public enum DataDisplayModes
{
Read,
Insert,
Update
}
public enum DataEntryTargets
{
Teams,
TeamMembers
}
The DataDisplayModes enumeration represents the current mode of the data entry UI—whether it's in read-only, insert, or update state. This value will guide how the UI is rendered dynamically based on user interaction or workflow context. The DataEntryTargets enumeration specifies the target entity for data entry operations, indicating whether the user is interacting with the Teams table or the TeamMembers table.
Next, add a view model class named MasterDetailViewModel to the Models folder. This class will serve as the data container for our master-detail UI, allowing us to coordinate the display and editing of teams and their members. Define the class with the following properties:
public class MasterDetailViewModel
{
public List<Team> Teams { get; set; }
public Team SelectedTeam { get; set; }
public TeamMember SelectedTeamMember { get; set; }
public DataEntryTargets DataEntryTarget { get; set; }
public DataDisplayModes DataDisplayMode { get; set; }
}
As you can see, the MasterDetailViewModel class contains five key properties: Teams, SelectedTeam, SelectedTeamMember, DataEntryTarget, and DataDisplayMode.
Teams holds the list of Team entities to be displayed in the master grid. SelectedTeam refers to the specific Team selected when the user clicks the Manage Team button. SelectedTeamMember points to the TeamMember selected from the detail grid for editing. DataEntryTarget indicates whether the master table (Teams) or the detail table (TeamMembers) is currently being modified. DataDisplayMode specifies the current mode of the data entry operation—whether the UI is in Read, Insert, or Update mode.
At this stage your Models folder should resemble this:
Next, add a new controller named TeamsController in the Controllers folder.
The TeamsController handles CRUD operations for the Teams table. To enable database access, inject an instance of AppDbContext into its constructor as shown below:
public class TeamsController : Controller
{
private readonly AppDbContext db;
public TeamsController(AppDbContext db)
{
this.db = db;
}
}
Next, let's implement the List() action to display the list of teams. This action initializes the MasterDetailViewModel and returns the Main view:
public IActionResult List()
{
MasterDetailViewModel model = new
MasterDetailViewModel
{
Teams = db.Teams.ToList(),
SelectedTeam = null,
SelectedTeamMember = null,
DataEntryTarget = DataEntryTargets.Teams,
DataDisplayMode = DataDisplayModes.Read
};
return View("Main", model);
}
In this method:
- A new instance of MasterDetailViewModel is created.
- The Teams property is populated using db.Teams.ToList(), which retrieves all team records from the database.
- Other properties are initialized to reflect the default state: no team or member selected, and the UI set to read-only mode for the master table.
The List() action returns the Main view, which we'll define in a later part of this series. This view will use the Teams property to render the master grid of teams.
As noted earlier, the List() action returns the Main view. This view will be responsible for rendering the master grid of teams using the data provided in the MasterDetailViewModel. Here's a preview of how the list of teams will appear in the UI:
The grid displays each team in a tabular format, allowing users to view and interact with the available records. In upcoming sections, we'll build out this view to support selection, editing, and navigation between master and detail components.
When a user clicks the Manage Team button in the master grid, the corresponding row is highlighted, and the selected Team is displayed for editing in the detail section. This behavior is handled by the Select() action, which updates the view model to reflect the selected team. Here's how the Select() action is defined:
[HttpPost]
public IActionResult Select(int teamId)
{
MasterDetailViewModel model = new
MasterDetailViewModel
{
Teams = db.Teams.ToList(),
SelectedTeam = db.Teams.Find(teamId),
SelectedTeamMember = null,
DataEntryTarget = DataEntryTargets.Teams,
DataDisplayMode = DataDisplayModes.Read
};
return View("Main", model);
}
The Select() action receives a teamId as a route parameter. It constructs a new MasterDetailViewModel instance, similar to the List() action, but with one key difference: the SelectedTeam property is set to the team corresponding to the provided teamId. This allows the UI to highlight the selected row and display the team's details for further interaction.
Once a team is selected, its details are displayed in the master grid as shown below:
Next, we'll implement two actions to handle the insertion of a new team: InsertEntry() and InsertSave(). The first action prepares the UI for data entry, while the second persists the new team to the database. Here's how these actions are defined:
[HttpPost]
public IActionResult InsertEntry()
{
MasterDetailViewModel model = new
MasterDetailViewModel
{
Teams = db.Teams.ToList(),
SelectedTeam = null,
SelectedTeamMember = null,
DataEntryTarget = DataEntryTargets.Teams,
DataDisplayMode = DataDisplayModes.Insert
};
return View("Main", model);
}
[HttpPost]
public IActionResult InsertSave(Team team)
{
db.Teams.Add(team);
db.SaveChanges();
MasterDetailViewModel model = new
MasterDetailViewModel
{
Teams = db.Teams.ToList(),
SelectedTeam = db.Teams.Find(team.TeamID),
SelectedTeamMember = null,
DataEntryTarget = DataEntryTargets.Teams,
DataDisplayMode = DataDisplayModes.Read
};
return View("Main", model);
}
The InsertEntry() action is triggered when the Insert Team button is clicked at the top of the master grid. This action prepares the view model for data entry by: Setting DataEntryTarget to Teams, indicating that we're adding a new team. And by setting DataDisplayMode to Insert, which renders the UI in entry mode. When DataDisplayMode is set to Insert, the UI renders a form like this:
When you enter the team details—such as Name and Description—and click Save, the InsertSave() action is invoked. This action uses the Add() and SaveChanges() methods to persist the new Team to the database. After saving, the newly added team is set as the selected item in the master grid by assigning it to the SelectedTeam property of the MasterDetailViewModel. To switch the UI back to display mode, we set DataDisplayMode to Read. This ensures the newly added team is rendered in the master grid like this:
Next, we'll implement two actions to handle the update operation for an existing team: UpdateEntry() prepares the UI for editing by loading the selected team's details. And UpdateSave() persists the changes to the database and refreshes the master grid. Here's how these actions are defined:
[HttpPost]
public IActionResult UpdateEntry(int teamId)
{
MasterDetailViewModel model = new
MasterDetailViewModel
{
Teams = db.Teams.ToList(),
SelectedTeam = db.Teams.Find(teamId),
SelectedTeamMember = null,
DataEntryTarget = DataEntryTargets.Teams,
DataDisplayMode = DataDisplayModes.Update
};
return View("Main", model);
}
[HttpPost]
public IActionResult UpdateSave(Team team)
{
db.Teams.Update(team);
db.SaveChanges();
MasterDetailViewModel model = new
MasterDetailViewModel
{
Teams = db.Teams.ToList(),
SelectedTeam = team,
SelectedTeamMember = null,
DataEntryTarget = DataEntryTargets.Teams,
DataDisplayMode = DataDisplayModes.Read
};
return View("Main", model);
}
The UpdateEntry() action is triggered when the Edit button is clicked for a specific team. The teamId is passed as a route parameter, which we use to fetch the corresponding Team from the database. This team is then assigned to the SelectedTeam property of the MasterDetailViewModel. We also set: DataEntryTarget to Teams, indicating the context of the update. And DataDisplayMode to Update, so the UI renders in edit mode.
When you edit a team record and click the Save button, the UpdateSave() action is executed. Inside this action, we use the Update() and SaveChanges() methods to persist the changes to the selected Team record. After the update is complete, we toggle the DataDisplayMode to Read to indicate that the edit operation has concluded. This refreshes the UI and displays the updated team details in the master grid.
If you click the Delete button while a team is being displayed for editing (as shown in earlier figures), the Delete() action is invoked.
[HttpPost]
public IActionResult Delete(int teamId)
{
Team team = db.Teams.Find(teamId);
db.Teams.Remove(team);
db.SaveChanges();
MasterDetailViewModel model = new
MasterDetailViewModel
{
Teams = db.Teams.ToList(),
SelectedTeam = null,
SelectedTeamMember = null,
DataEntryTarget = DataEntryTargets.Teams,
DataDisplayMode = DataDisplayModes.Read
};
return View("Main", model);
}
Inside the Delete() action, we first locate the Team to be deleted using Find(). Once retrieved, we use the Remove() and SaveChanges() methods to delete the record from the database. Since the team no longer exists, we also set the SelectedTeam property of the MasterDetailViewModel to null. This ensures the UI no longer attempts to display a deleted entity and cleanly resets the selection state.
If you decide to cancel the data entry process and return to read mode for the currently selected team, you can click the Cancel button. This triggers the CancelEntry() action, which resets the UI state without saving any changes. Here's the implementation:
[HttpPost]
public IActionResult CancelEntry(int teamId)
{
MasterDetailViewModel model = new
MasterDetailViewModel
{
Teams = db.Teams.ToList(),
SelectedTeam = db.Teams.Find(teamId),
SelectedTeamMember = null,
DataEntryTarget = DataEntryTargets.Teams,
DataDisplayMode = DataDisplayModes.Read
};
return View("Main", model);
}
We achieve this by simply setting the DataDisplayMode to Read. This exits the data entry mode and restores the standard display view. Note that the team remains selected in the master grid but it is no longer in edit or insert mode.
Finally, we need an action that clears the current team selection—typically used when initiating the insertion of a new team. For example, when the Insert Team button is clicked, we want to remove any existing selection to avoid confusion between editing and inserting. This is accomplished using the CancelSelection() action:
[HttpPost]
public IActionResult CancelSelection()
{
MasterDetailViewModel model = new
MasterDetailViewModel
{
Teams = db.Teams.ToList(),
SelectedTeam = null,
SelectedTeamMember = null,
DataEntryTarget = DataEntryTargets.Teams,
DataDisplayMode = DataDisplayModes.Read
};
return View("Main", model);
}
Inside the CancelSelection() action, we explicitly set the SelectedTeam property of the MasterDetailViewModel to null. This clears any active selection in the master grid, ensuring that the UI reflects a neutral state—ideal when preparing to insert a new team or exit from a previous selection.
This completes the TeamsController. For brevity, we've kept model validation and error handling minimal. In a production-ready ASP.NET Core application, you can enhance robustness by applying data validation attributes and implementing structured error handling as needed.
In the next part of this series, we'll introduce the TeamMembersController, which will handle CRUD operations for the detail records associated with each team.
That's all for now—may your code be clean, and your purpose ever clearer. With this soft intention, I lay my pen to rest.