Building a Cardstack app - Part 2

08 December 2017

At the end of Part 1, we managed to set up and configure the Cardstack plugins we needed and were greeted with a “Welcome to Cardstack!” header as our reward.

Welcome message appears

Here, in Part 2, we’ll add the rental listing on the main page and create the rental details page, learning how to use Cardstack components, search and all the code Cardstack generates for us in the process.

Listing rentals on the main page

The first feature we’ll implement is listing the rentals on the main page and filtering them by city.

We established that the content on the main page is rendered by the cardstack/page-page component and we currently only have the title of the page displayed:

1
2
<!-- app/templates/components/cardstack/page-page.hbs -->
<h1>{{content.title}}</h1>

We’ll need to add the listing below it.

As I mentioned in Part 1, Cardstack is a search-first web framework so we’re going to trigger a back-end search to fetch the rental items. But first, let’s install the cardstack search add-on (and then restart):

1
$ ember install @cardstack/search

Now, let’s define the search parameters for the main page. We want to fetch all rentals on the main page, so let’s add a mainQuery property to the page to specify this. The properties of the main page are defined in the models.js file, so let’s edit that:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// cardstack/seeds/development/models.js
const Factory = require('@cardstack/test-support/jsonapi-factory');
(...)

function initialModels() {
  let factory = new Factory();

  factory.addResource('content-types', 'pages')
    .withAttributes({
      'routing-field': 'permalink'
    })
    .withRelated('fields', [
      factory.addResource('fields', 'title')
        .withAttributes({
          'field-type': '@cardstack/core-types::string'
        }),
      factory.addResource('fields', 'body')
        .withAttributes({
          'field-type': '@cardstack/core-types::string'
        }),
      factory.addResource('fields', 'permalink')
        .withAttributes({
          'field-type': '@cardstack/core-types::string'
        }),
      factory.addResource('fields', 'main-query')
        .withAttributes({
          'field-type': '@cardstack/core-types::object'
        }),
    ]);

  (...)

  factory.addResource('pages').withAttributes({
    permalink: " ",
    title: "Welcome!",
    mainQuery: {
      type: 'rentals'
    }
  });

  return factory.getModels();
}

We added a main-query field that is of type object, and set it to fetch all rentals on the main page, by only specifying the rentals type, without any more parameters.

Integrating search results into pages

We should now add search results to the main page.

We do that by adding the cardstack-search smart component which will trigger the search to the backend. In its simplest form, it takes a query object in its query attribute that defines the search parameters.

1
2
3
4
5
6
7
8
9
10
// app/templates/components/cardstack/page-page.hbs
<h1>{{content.title}}</h1>

{{#with content.mainQuery as |query|}}
  {{#cardstack-search query=query as |item|}}
    <article class="listing">
      <h3><a href={{cardstack-url item}} >{{item.title}}</a></h3>
    </article>
  {{/cardstack-search}}
{{/with}}

Our search-enabled app reloads and duly sends a request to /api/rentals which runs into a 404, because that endpoint doesn’t exist, Cardstack doesn’t know squat about the rentals type.

Now comes some dark magic.

Let’s open the schema definition file again, and define a rental type.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// cardstack/seeds/development/models.js
const Factory = require('@cardstack/test-support/jsonapi-factory');
(...)

function initialModels() {
  (...)
  factory.addResource('content-types', 'rentals')
    .withRelated('fields', [
      factory.addResource('fields', 'title')
        .withAttributes({
          'field-type': '@cardstack/core-types::string'
        }),
      factory.addResource('fields', 'owner')
        .withAttributes({
          'field-type': '@cardstack/core-types::string'
        }),
      factory.addResource('fields', 'city')
        .withAttributes({
          'field-type': '@cardstack/core-types::string'
        }),
      factory.addResource('fields', 'property-type')
        .withAttributes({
          'field-type': '@cardstack/core-types::string'
        }),
      factory.addResource('fields', 'bedrooms')
        .withAttributes({
          'field-type': '@cardstack/core-types::integer'
        }),
      factory.addResource('fields', 'description')
        .withAttributes({
          'field-type': '@cardstack/core-types::string'
        })
    ]);
}

That is the class definition of the Rental type, now let’s also add a handful of rentals.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// cardstack/seeds/development/models.js
const Factory = require('@cardstack/test-support/jsonapi-factory');
(...)

function initialModels() {
  (...)

  factory.addResource('rentals').withAttributes({
    "title": "Grand Old Mansion",
    "owner": "Veruca Salt",
    "city": "San Francisco",
    "property-type": "Estate",
    "bedrooms": 15,
    "description": "This grand old mansion sits on over 100 acres of rolling hills and dense redwood forests."
  });

  factory.addResource('rentals').withAttributes({
    "title": "Urban Living",
    "owner": "Mike Teavee",
    "city": "Seattle",
    "property-type": "Condo",
    "bedrooms": 1,
    "description": "A commuters dream. This rental is within walking distance of 2 bus stops and the Metro."
  });

  factory.addResource('rentals').withAttributes({
    "title": "Downtown Charm",
    "owner": "Violet Beauregarde",
    "city": "Portland",
    "property-type": "Apartment",
    "bedrooms": 3,
    "description": "Convenience is at your doorstep with this charming downtown rental. Great restaurants and active night life are within a few feet."
  });
}

Every time we change the schema definition, we have to restart our app so that server-side operations can take place. When we load the main page of our app again, we see that it’s actually working, the title of each listing is displayed:

Rental listing

Please notice (because it’s awesome!) that we never implemented any API endpoints. By virtue of having defined the Rental type, Cardstack (more precisely, the @cardstack/jsonapi package) generated those CRUD endpoints for us.

Displaying rental properties

As a next step, let’s now display more properties for each rental in the listing. Let’s do that by rendering a component for each search result item instead of writing the markup inline.

1
2
3
4
5
6
7
8
// app/templates/components/cardstack/page-page.hbs
<h1>{{content.title}}</h1>

{{#with content.mainQuery as |query|}}
  {{#cardstack-search query=query as |item|}}
    {{cardstack-content content=item format="listing"}}
  {{/cardstack-search}}
{{/with}}

The cardstack-content is the workhorse of UI customization in Cardstack. It takes a content object for the object to be rendered and a format property that defines how it should be rendered. The cardstack-content component then goes on to render the component that is named cardstack/${type}-${format}.

At any rate, when we save the above template, Cardstack will tell us which component we need to define by including its name in the error message on the page.

No rental-listing component

So let’s create said component:

1
$ ember g component cardstack/rental-listing

And move the few lines that we had in the each loop to the component’s template for each listing:

1
2
3
4
<!-- app/templates/components/cardstack/rental-listing.hbs -->
<article class="listing">
  <h3><a href={{cardstack-url content}}>{{content.title}}</a></h3>
</article>

The only modification we had to make was to write content instead of item, because that’s what the resource object is bound to inside cardstack-component.

With this change, we’re back to having the title for each property listed. We can now add all the other information:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- app/templates/components/cardstack/rental-listing.hbs -->
<article class="listing">
  <h3><a href={{cardstack-url content}} >{{content.title}}</a></h3>
  <div class="detail owner">
    <span>Owner:</span> {{content.owner}}
  </div>
  <div class="detail type">
    <span>Type:</span> {{rental-property-type content.propertyType}} - {{content.propertyType}}
  </div>
  <div class="detail location">
    <span>Location:</span> {{content.city}}
  </div>
  <div class="detail bedrooms">
    <span>Number of bedrooms:</span> {{content.bedrooms}}
  </div>
  <div class="detail bedrooms">
    <span>Sleeps:</span> {{content.sleeps}}
  </div>
</article>

It all works, the rental list items now display all information correctly:

Rental listing shows all info

Extending generated classes

Taking a second look at our page, we notice that the ‘Sleeps’ property is not displayed correctly, it displays “Enter undefined” instead of the number of people the rental can sleep. Let’s fix this next.

If we take a closer look at the template, we see that {{content.sleeps}} is rendered, where content is a rental model object. However, the Rental class (that we inherited from the original SuperRentals app) doesn’t have a sleeps property so it makes sense that its value is undefined.

Now, here is another thing that I really like about Cardstack.

Taking the model schema (that we defined in models.js), it automatically generates Ember Data classes for each type in our definition. The @cardstack/models package has a code generator that accomplishes this and you can see these generated models if you open the app’s vendor.js and search for 'models/generated’.

The SuperRentals app defined a Rental class so let’s rewrite this by extending the generated model and defining the sleeps computed property therein:

1
2
3
4
5
6
7
8
9
// app/models/rental.js
import Rental from '@cardstack/models/generated/rental';
import { computed } from '@ember/object';

export default Rental.extend({
  sleeps: computed('bedrooms', function() {
    return this.get('bedrooms') * 4;
  })
});

If we didn’t need the sleeps CP we wouldn’t even have to have a file for the model. Cardstack takes the power of conventions even further and saves us from having to write boilerplate code for the models, too.

Rental details page

You might have noticed that we have a link for each rental in the list where the href is given by a call to cardstack-url. I explained this helper in Part 1, explaining that it takes a resource type and an id to create a URL from.

Here, we use it differently, only passing in a resource object. Since that object has an id, the URL can be generated. For a rental object with an id of 24, the URL will be /rentals/24.

Following one of the links, we transition to a URL like /rentals/1 and are greeted with the “No such component cardstack/rental-page” error message.

No rental-page component

By now, we know really well what should be done to fix this.

Let’s create a cardstack/rental-page component and write in its template what we want to see on the rental (details) page. The content property will be bound to the rental within the template:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- app/templates/components/cardstack/rental-page.hbs -->
<div class="jumbo show-listing">
  <h2 class="title">{{content.title}}</h2>
  <div class="right detail-section">
    <div class="detail owner">
      <strong>Owner:</strong> {{content.owner}}
    </div>
    <div class="detail">
      <strong>Type:</strong> {{rental-property-type content.propertyType}} - {{content.propertyType}}
    </div>
    <div class="detail">
      <strong>Location:</strong> {{content.city}}
    </div>
    <div class="detail">
      <strong>Number of bedrooms:</strong> {{content.bedrooms}}
    </div>
    <div class="detail bedrooms">
      <strong>Sleeps:</strong> {{content.sleeps}}
    </div>
    <p>&nbsp;</p>
    <p class="description">{{content.description}}</p>
  </div>
</div>

We’ll need to add a tiny css rule to make it look good:

1
2
3
4
// app/styles/app.css
.jumbo > .detail-section {
  float: none;
}

Now our rental details page looks swell, too:

Rental page

In the next part

Other than the “static” About and Contact pages, what’s missing from the original SuperRentals app is the displaying of rental images, toggling between a wide and a normal image view and the city filter.

We’ll add all of these in Part 3.

If you’re interested in learning more about Cardstack, or contributing to it, check out the official Cardstack site and the source code repository. If you have any questions, hop into our chat and ask away!


Part 3 is now available.