Lesson 8

[Sprint Two] Displaying Client List from Firestore

The importance of dumb/presentational components

PRO

Lesson Outline

A Quick Note

I have no idea why this happened, and it might not be happening for you, but at this point I started getting this strange errors when trying to run E2E tests locally or serve the app with ng serve:

ReferenceError: __NG_CLI_RESOURCE__0 is not defined

and the application would just display as a white page. To fix this I just had to clear the Angular cache (delete the .angular/cache folder in the project). I have no idea why this happened, but make sure to do the same if you are running into that issue!

Sprint Two

It's time to begin out next sprint. To recap for those of you not following along with the project management module, these are the tasks that we are including in this sprint:

  • #1 feat: record a client's details
  • #2 feat: see a list of all clients
  • #3 feat: edit a clients details
  • #5 feat: access the full details of a specific client

Basically, all of the items we didn't end up getting to in the last sprint because we spent so much time setting up our development environment! With any luck, most of that will be behind us now and it will be smooth sailing from here.

It doesn't particularly matter which one we start with, but in general I like to go in an order where we are gradually adding complexity one bit at a time. For example, if we were to start with #5:

  • As a massage therapist, I want to be able to access the full details of a specific client, so that I can access all of the information that is required to complete my services

Completing this one would require that #2 was already complete, as we can't really access full details for a client if there are no clients to select in the first place.

Then we might try to decide whether to go with #2 or #1 first. Going with #1 seems obvious since it is number one. But the order of our user stories as we created them was arbitrary, we just happened to create that one first. It is the order in the backlog that is relevant, not the number of the issue.

In this case I think it makes the most sense to start with adding the ability to see a list of clients as it is going to be the first thing we see, it should be easy to implement, and we can just use some dummy data to begin with if we want.

Don't fret too much over this though, it won't matter too much in the end. If you did start with #5 you will just have a more complex E2E test and you will end up creating a lot of unit tests all at once to satisfy the functionality. Then when you come back to implement E2E tests for the simpler user stories you might find that they already pass (because you would have already need to implement a bunch of that functionality), which isn't ideal but it's not the end of the world (you should still intentionally break the functionality if the test passes by default just to make sure the test is doing what it is supposed to do).

Feature: See a list of all clients

Let's start building out the following user story:

User story: As a massage therapist, I want to be able to see a list of all clients, so that I can have a birds eye view of my entire client base


Project management

Remember to move the card for this user story to the Test column, and create a new task branch to work on.


Then we need to add a new E2E test for this user story. Although technically it doesn't matter which file you put your test in, it's a good idea to have some sort of organisation/structure if you are going to have a significant amount of tests. We already have a home.cy.ts, but we are going to create an additional clients.cy.ts file.

In general, I like to create a separate spec file for each page in the application that we are testing. Since this E2E test will involve testing functionality on the clients page, it will live in the clients.cy.ts file.

You might find it helpful to first just write comments of how you want to test this functionality:

Create a file at cypress/e2e/clients.cy.ts and add the following:

describe('Clients', () => {
  beforeEach(() => {
    cy.login();
    cy.visit('/clients');
  });

  it('can see a list of clients', () => {
    // add a test client to the firestore database
    // grab items from the ion-list
    // expect that the test client is in the list
  });
});

We are utilising our login command here before each test to bypass authentication. Then we want to add some test data to the firestore database, visit the clients route, and then check that the data is in the list.

IMPORTANT: We are modifying data in the Firestore database with this test. It is very important that this is happening on a test/development Firebase project or on the Firebase emulators, not your production Firebase project with your production Firestore data. Running these tests against a production database is just asking for trouble. If you have been following along with this example so far you should be safe because we set up our tests to use the emulators.

Let's replace that with the real implementation now:

Modify the test to reflect the following:

import { getItemsInList } from '../support/utils';

describe('Clients', () => {
  const testDocId = 'abc123';

  beforeEach(() => {
    cy.login();
    cy.visit('/clients');
    cy.callFirestore('delete', 'clients');
  });

  it('can see a list of clients', () => {
    cy.callFirestore('set', `clients/${testDocId}`, {
      name: {
        first: 'Josh',
        last: 'Morony',
      },
    });

    const listOfClients = getItemsInList();
    listOfClients.should('contain.text', 'Josh Morony');
  });

});

We will also need to add the following helper function:

export const getItemsInList = () => cy.get('[data-test=list] ion-item');

Since we have cypress-firebase set up which allows us to call cy.callFirestore the test set up is quite simple. We add some test data at the start of the test, we grab the list of clients (which does not exist yet), and then we check if it contains an item for the data we just added. Note that we are making sure to delete all the Firestore data in the beforeEach hook - we don't want test data from one test to affect another test.

Ok, let's run our E2E tests and see what happens:

Failing E2E test

You can see that we are successfully getting to the Clients page, but we are getting this error:

expected [data-test=list] ion-item to contain text Josh Morony, but the text was ''

Great, we have our failing test now. We can move on to creating some unit tests for our clients page.


Project management

I'll stop leaving hints like this from now on, but this would be a good time to commit what you have and push to the remote repo.


This is the part where you open up the client-dashboard.page.spec.ts file:

import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';

import { ClientDashboardPage } from './client-dashboard.page';

describe('ClientDashboardPage', () => {
  let component: ClientDashboardPage;
  let fixture: ComponentFixture<ClientDashboardPage>;

  beforeEach(waitForAsync(() => {
    TestBed.configureTestingModule({
      declarations: [ ClientDashboardPage ],
      imports: [IonicModule.forRoot()]
    }).compileComponents();

    fixture = TestBed.createComponent(ClientDashboardPage);
    component = fixture.componentInstance;
    fixture.detectChanges();
  }));

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

Look at it... and say... now what? Let's think about what we are trying to do here. We are trying to display a list of clients, so we are going to need an array of clients to actually display. Since we are utilising @ngrx/component-store for this application, we are actually going to set up this stream of client data in a store for the client-list component.

We could immediately move our focus to creating and testing the store, but in the interest of letting the test failures guide us, let's first attempt to satisfy our failing E2E test by creating the list in the template (pretending that our store already exists).

Modify client-dashboard.page.html to reflect the following:

<ion-header>
  <ion-toolbar>
    <ion-title data-test="page-title">Clients</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-list data-test="list">
    <ion-item *ngFor="let client of clientsStore.clients$ | async">
      <ion-label>{{client.name.first}} {{client.name.last}}</ion-label>
    </ion-item>
  </ion-list>
</ion-content>

We have implemented this how we want it to work. At the moment, I am intending to have a clients store that will provide an observable stream of selected state on the clients$ class member. From previous planning, we also know the structure of the client object. The reason we have not added any new tests for this code in the template is because this is what our E2E test is specifically testing already (also note that we added a data-test attribute for our test).

Now we are going to get errors because clientsStore does not exist on our client list component. Now we are going to tackle creating this store.

Create a file at clients/data-access/clients.store.ts and add the following:

import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { ClientShellModule } from '../feature/client-shell/client-shell.module';

interface ClientName {
  first: string;
  last: string;
}

interface SurveyResponse {
  values: string;
}

export interface Client {
  name: ClientName;
  email: string;
  phone: string;
  appointments: string[];
  notes: string;
  survey: SurveyResponse[];
}

export interface ClientsState {
  clients: Client[];
}

@Injectable({
  providedIn: ClientShellModule,
})
export class ClientsStore extends ComponentStore<ClientsState> {}

We don't want to add any functionality yet because we haven't written a test for it. We are just setting up the basic structure of the store here, as well as the definition for our Client interface.

Also, we're doing something a little tricky here. Typically, a component store will only be provided to one component. In this case, we would not supply the providedIn option to @Injectable() and we would manually add the provided to the components @Component metadata in the providers array. However, we are actually going to use this same store for all of our client features. At the moment, we just have client-dashboard but eventually we will have client-detail and client-survey as well.

Although it's key purpose is for managing local component state, component store can quite nicely be used for shared state. We could just use providedIn: root to provide this store globally, but that isn't really our intent here. It lives inside of the clients feature, so it should only be used by clients features. If we wanted to share this globally then we should move it to the shared folder, and we have no reason to do that right now. Instead, we are supplying the specific module we want to provide this store in. By providing it in the ClientShellModule it will only be accessible to our clients features.

Now let's move on to creating our first tests for the store.

Create a file at clients/data-access/clients.store.spec.ts and add the following:

import { TestBed } from '@angular/core/testing';
import { subscribeSpyTo } from '@hirez_io/observer-spy';
import { ClientsStore } from './clients.store';

describe('ClientsStore', () => {
  let service: ClientsStore;

  beforeEach(() => {
    jest.restoreAllMocks();
    jest.clearAllMocks();

    TestBed.configureTestingModule({
      providers: [ClientsStore],
    });

    service = TestBed.inject(ClientsStore);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  describe('selector: clients$', () => {
    it('should return empty array by default', () => {
      const observerSpy = subscribeSpyTo(service.clients$);
      expect(observerSpy.getFirstValue()).toEqual([]);
    });
  });
});

Our goal here is to have clients$ be a stream of the clients data from the current state, which by default will be an empty array. This isn't going to do much for us right now because we never load anything into that state, but that's not the error we are solving at the moment.

Once again, before we can run our tests we need to make sure clients$ is actually defined in the store:

import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { EMPTY } from 'rxjs';
import { ClientShellModule } from '../feature/client-shell/client-shell.module';

interface ClientName {
  first: string;
  last: string;
}

interface SurveyResponse {
  values: string;
}

export interface Client {
  name: ClientName;
  email: string;
  phone: string;
  appointments: string[];
  notes: string;
  survey: SurveyResponse[];
}

export interface ClientsState {
  clients: Client[];
}

@Injectable({
  providedIn: ClientShellModule,
})
export class ClientsStore extends ComponentStore<ClientsState> {
  clients$ = EMPTY;
}

Again, we are just using EMPTY from rxjs so that clients$ is an observable but we don't have any implementation details. If we run our unit tests now we can see that it fails:

 FAIL  src/app/clients/data-access/clients.store.spec.ts
  ● ClientsStore - selector: clients$ - should return empty array by default

    expect(received).toEqual(expected) // deep equality

    Expected: []
    Received: undefined

NOTE: We will also have a failing test for the client list page because of what we added to the template, but we are going to ignore that for now.

Now let's add the implementation to the store:

@Injectable({
  providedIn: ClientShellModule,
})
export class ClientsStore extends ComponentStore<ClientsState> {
  readonly clients$ = this.select((state) => state.clients);

  constructor() {
    super({ clients: [] });
  }
}

and now it should pass. Now let's focus on that other failing test:

 FAIL  src/app/clients/feature/client-dashboard/client-dashboard.page.spec.ts
  ● ClientDashboardPage - should create

    TypeError: Cannot read properties of undefined (reading 'clients$')

Our client list page is trying to access the store but we aren't injecting it yet, let's do that now.

Modify client-dashboard.page.ts to reflect the following:

import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ClientsStore } from '../../data-access/clients.store';

@Component({
  selector: 'app-client-dashboard',
  templateUrl: './client-dashboard.page.html',
  styleUrls: ['./client-dashboard.page.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ClientDashboardPage implements OnInit {
  constructor(public clientsStore: ClientsStore) {}

  ngOnInit() {}
}
PRO

Thanks for checking out the preview of this lesson!

The full version of this lesson is only available to pro members. If you would like full access to this module and all of the other pro modules on Elite Ionic you can become a pro member (or log in if you are already a member).