Lesson 2.6: Refactoring to PlayerComponent

One of the Blazor’s strengths is being able to componentize UIs into smaller pieces that can be composed into a page. We haven’t taken advantage of that yet in our game screen. So let’s start to do that by refactoring our MainScreen page to pull out the Player Data table into its own component. This allows us to focus our testing of the component without the whole page, and allows us to re-use the Player component in other pages (if we need to).

Let’s learn about Blazor components. Components are implemented in Razor component files (.razor) using a combination of C# and HTML markup, just list the pages we’ve seen so far.

Create PlayerComponent

We’re going to start by creating a new Razor component. In the SimpleRPG.Game project Shared folder, right click and select Add > Razor Component from the context menu. By convention Blazor projects use the Shared folder for defining components, but you can create them anywhere in this project.

Fig 1 – Add new razor component

In this dialog, name the component PlayerComponent.razor and click the Add button. This will create a simple component with a header and empty @code block.

For this component, we’re going to copy the Player Data table from the MainScreen.razor page into this file, since it is a self-contained piece of user interface that displays the Player class information.

        <Table Borderless="true" Narrow="true">
            <TableHeader>
                <TableHeaderCell RowSpan="2">Player Data</TableHeaderCell>
            </TableHeader>
            <TableBody>
                <TableRow>
                    <TableRowCell>Name:</TableRowCell>
                    <TableRowCell>@ViewModel.CurrentPlayer.Name</TableRowCell>
                </TableRow>
                <TableRow>
                    <TableRowCell>Class:</TableRowCell>
                    <TableRowCell>@ViewModel.CurrentPlayer.CharacterClass</TableRowCell>
                </TableRow>
                <TableRow>
                    <TableRowCell>Hit points:</TableRowCell>
                    <TableRowCell>@ViewModel.CurrentPlayer.HitPoints</TableRowCell>
                </TableRow>
                <TableRow>
                    <TableRowCell>Gold:</TableRowCell>
                    <TableRowCell>@ViewModel.CurrentPlayer.Gold</TableRowCell>
                </TableRow>
                <TableRow>
                    <TableRowCell>XP:</TableRowCell>
                    <TableRowCell>@ViewModel.CurrentPlayer.ExperiencePoints</TableRowCell>
                </TableRow>
                <TableRow>
                    <TableRowCell>Level:</TableRowCell>
                    <TableRowCell>@ViewModel.CurrentPlayer.Level</TableRowCell>
                </TableRow>
            </TableBody>
        </Table>

We have 6 references to @ViewModel.CurrentPlayer in this markup. Obviously we need to have a Player object in this component to be able to support binding its properties to the UI elements. Also the Player need to be passed into this component from the parent component/page.

Luckily Blazor components can have component parameters, which are defined using public properties on the component class with the [Parameter] attribute. These parameters are used to specify arguments for a component in markup. So, let’s add a parameter to the PlayerComponent.

@code {
    [Parameter]
    public Player Player { get; set; } = new Player();
}

This code defines a Player property that is initialized to an empty Player object, but can be get or set from parent components or pages.

Now, we need to clean up the component <Table> markup to use @Player for binding rather than @ViewModel.CurrentPlayer. The resulting full code of the component will be:

<Table Borderless="true" Narrow="true">
    <TableHeader>
        <TableHeaderCell RowSpan="2">Player Data</TableHeaderCell>
    </TableHeader>
    <TableBody>
        <TableRow>
            <TableRowCell>Name:</TableRowCell>
            <TableRowCell>@Player.Name</TableRowCell>
        </TableRow>
        <TableRow>
            <TableRowCell>Class:</TableRowCell>
            <TableRowCell>@Player.CharacterClass</TableRowCell>
        </TableRow>
        <TableRow>
            <TableRowCell>Hit points:</TableRowCell>
            <TableRowCell>@Player.HitPoints</TableRowCell>
        </TableRow>
        <TableRow>
            <TableRowCell>Gold:</TableRowCell>
            <TableRowCell>@Player.Gold</TableRowCell>
        </TableRow>
        <TableRow>
            <TableRowCell>XP:</TableRowCell>
            <TableRowCell>@Player.ExperiencePoints</TableRowCell>
        </TableRow>
        <TableRow>
            <TableRowCell>Level:</TableRowCell>
            <TableRowCell>@Player.Level</TableRowCell>
        </TableRow>
    </TableBody>
</Table>

@code {
    [Parameter]
    public Player Player { get; set; } = new Player();
}

Use the PlayerComponent

Now that we have a component defined, we can use it in other components or pages. Let’s replace the Player Data table in MainScreen.razor with the PlayerComponent. First, delete the Table component completely, and replace it with the highlighted line of markup.

@page "/"
@inject GameSession ViewModel

<Row Style="height: 5vh; min-height: 32px">
    <Column ColumnSize="ColumnSize.Is12" Style="background-color: aliceblue">
        <Heading Size="HeadingSize.Is3">Simple RPG</Heading>
    </Column>
</Row>
<Row Style="height: 60vh">
    <Column ColumnSize="ColumnSize.Is3.OnWidescreen.Is12" Style="background-color: aquamarine">
        <PlayerComponent Player="@ViewModel.CurrentPlayer" />
        <Button Color="Color.Secondary" Outline="true" Clicked="@ViewModel.AddXP">Add XP</Button>
    </Column>
    <Column ColumnSize="ColumnSize.Is9.OnWidescreen.Is12" Style="background-color: beige">
        Game Data
    </Column>
</Row>
<Row Style="height: 30vh">
    <Column ColumnSize="ColumnSize.Is3.OnWidescreen.Is12" Style="background-color: burlywood">
        Inventory/Quest
    </Column>
    <Column ColumnSize="ColumnSize.Is9.OnWidescreen.Is12" Style="background-color: lavender">
        Combat/Movement Controls
    </Column>
</Row>

We use markup to define the <PlayerComponent> just like any other component/UI element. And notice that it has a Player attribute that we bind to this page’s @ViewModel.CurrentPlayer. We have now connected up that component to our view model property pretty easily.

We can build and run the game (Ctrl+F5). We will get the exact same screen we had before. 🙂 But now the Player Data table is its own component that we can develop, test, and share on its own.

Component Testing

A benefit of having a self-contained component, like PlayerComponent, is that we can build tests to exercise the component in isolation… making sure it is fully tested on its own, so its consumers don’t have to work about testing its bahvior.

Again we will use bUnit to test the PlayerComponent (just like we did for our MainScreen page in Lesson 2.4). In the SimpleRPG.Game.Tests project Shared folder, create a new PlayComponentTests class.

using Bunit;
using SimpleRPG.Game.Engine.Models;
using SimpleRPG.Game.Shared;
using SimpleRPG.Game.Tests.Mocks;
using Xunit;

namespace SimpleRPG.Game.Tests.Shared
{
    public class PlayerComponentTests
    {
        [Fact]
        public void SimpleRender_WithEmptyPlayer()
        {
            // arrange
            using var ctx = new TestContext();
            ctx.Services.AddBlazoriseServices();

            // act
            var cut = ctx.RenderComponent<PlayerComponent>();

            // assert
            var expected =
@"    <table class=""table table-sm table-borderless"" style="""">
      <thead class="""" style="""">
        <tr>
          <th scope=""col"" class="""" style=""""  rowspan=""2"">
            Player Data
          </th>
        </tr>
      </thead>
      <tbody class="""" style="""">
        <tr class="""" style="""" >
          <td class="""" style="""" >
            Name:
          </td>
          <td class="""" style="""" >
          </td>
        </tr>
        <tr class="""" style="""" >
          <td class="""" style="""" >
            Class:
          </td>
          <td class="""" style="""" >
          </td>
        </tr>
        <tr class="""" style="""" >
          <td class="""" style="""" >
            Hit points:
          </td>
          <td class="""" style="""" >
            0
          </td>
        </tr>
        <tr class="""" style="""" >
          <td class="""" style="""" >
            Gold:
          </td>
          <td class="""" style="""" >
            0
          </td>
        </tr>
        <tr class="""" style="""" >
          <td class="""" style="""" >
            XP:
          </td>
          <td class="""" style="""" >
            0
          </td>
        </tr>
        <tr class="""" style="""" >
          <td class="""" style="""" >
            Level:
          </td>
          <td class="""" style="""" >
            0
          </td>
        </tr>
      </tbody>
    </table>
";
            cut.MarkupMatches(expected);
        }

        [Fact]
        public void SimpleRender_WithPlayer()
        {
            // arrange
            using var ctx = new TestContext();
            ctx.Services.AddBlazoriseServices();

            var testPlayer = new Player
            {
                Name = "TestPlayer",
                CharacterClass = "TestClass",
                HitPoints = 8,
                Gold = 10,
                ExperiencePoints = 101,
                Level = 1,
            };
            var parameter = ComponentParameterFactory.Parameter("Player", testPlayer);

            // act
            var cut = ctx.RenderComponent<PlayerComponent>(parameter);

            // assert
            var expected =
@"    <table class=""table table-sm table-borderless"" style="""">
      <thead class="""" style="""">
        <tr>
          <th scope=""col"" class="""" style=""""  rowspan=""2"">
            Player Data
          </th>
        </tr>
      </thead>
      <tbody class="""" style="""">
        <tr class="""" style="""" >
          <td class="""" style="""" >
            Name:
          </td>
          <td class="""" style="""" >
            TestPlayer
          </td>
        </tr>
        <tr class="""" style="""" >
          <td class="""" style="""" >
            Class:
          </td>
          <td class="""" style="""" >
            TestClass
          </td>
        </tr>
        <tr class="""" style="""" >
          <td class="""" style="""" >
            Hit points:
          </td>
          <td class="""" style="""" >
            8
          </td>
        </tr>
        <tr class="""" style="""" >
          <td class="""" style="""" >
            Gold:
          </td>
          <td class="""" style="""" >
            10
          </td>
        </tr>
        <tr class="""" style="""" >
          <td class="""" style="""" >
            XP:
          </td>
          <td class="""" style="""" >
            101
          </td>
        </tr>
        <tr class="""" style="""" >
          <td class="""" style="""" >
            Level:
          </td>
          <td class="""" style="""" >
            1
          </td>
        </tr>
      </tbody>
    </table>
";
            cut.MarkupMatches(expected);
        }
    }
}

Both tests follow our familiar pattern. Remember that we need to call ctx.Services.AddBlazoriseServices, since this component is using Blazorise components too. And notice the bUnit cut.MarkupMatches validation method. This method verifies that the component markup matches expected markup text that we define. This is a great way to make sure your component renders exactly what you expect — for different parameters, states, and events.

The first test validates the markup based on using an empty Player. The second test passes in a Player parameter with test data. In our tests, to pass a component parameter, you need to call ctx.RenderComponent<PlayerComponent>(parameter) with the required parameter. The code in test 2 is a good example of how to define the parameter and use it in RenderComponent.

These tests are really long because we build the expected HTML markup for the entire table. And, I wanted to make the expected markup human readable, so it is formatted out, rather being all on one line.

These tests also use the verbatim string C# feature with the @ symbol. This allows us to construct strings that would otherwise have special characters and even multi-line (as in our expected markup). It can also be used to simplify format strings, which we will cover in later lessons.

We can now run our tests and verify that the component renders the HTML we expected.

Follow Up

In this lesson, we used a parameter to pass data from the parent to the component. But there are other options for sharing data with a component:

  • Injecting the page ViewModel into the component
  • Creating a new ViewModel just for the component (and injecting that one)
  • Providing a data service that shares the Player data with the component
  • Cascading parameters.

Since the PlayerComponent is a read-only view of the Player, the simplest path was using a component parameter.

If we had more complex events and interactions within the component, we would likely decide to create (or share) a view model for the component. Data services (like authorization state) can be injected into components that requires that shared data. Cascading parameters are great to use parameters that can be shared with all child components.

We just need to be aware that there are multiple ways to pass data into a component, and that we need to decide which is the right tool to use for any particular component.

We now have our page and component and tests running, so let’s go on to the next lesson.

One thought on “Lesson 2.6: Refactoring to PlayerComponent

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s