Playing Next Lesson In
seconds

Let's Learn AdonisJS 7 #2.5

Components, Layouts, & Partials

In This Lesson

Learn all about EdgeJS components, layouts, and partials for reusable view markup. We'll discuss the differences between the three and dig into specifics on how to use components and their many features

Created by
@tomgobich
Published

In EdgeJS, there are two options for reusable markup partials and components.

  • Partials, reusable fragments that share the same state as their parent

  • Components, reusable fragments with isolated state, props, and slots

Partials

Partials are straightforward, reusable bits of markup that share the same state as their parent. They behave as though the markup written inside them was written directly in the parent, just with the bonus of reusability.

They can, however, also be used to keep things clean as well. So, let's add a partial to extract our availablePoints aggregation out of our page to clean this up a little bit.

node ace make:view partials/challenge/available_points
# DONE:    create resources/views/partials/challenge/available_points.edge
Copied!
@let(availablePoints = challenges.reduce((total, challenge) => total + challenge.points, 0))

@if (completedChallenges?.length)
  @let(completedPoints = completedChallenges.reduce((total, challenge) => total + challenge.points, 0))
  @assign(availablePoints = availablePoints - completedPoints)
@endif

<div>
  Total Points Available: {{ availablePoints }}
</div>
Copied!
  • resources
  • views
  • partials
  • challenge
  • available_points.edge

Since this shares the same state as its parent, we can openly use our challenges state available on our page. Next, we need to include this partial in our page. For that, there's a special @include tag that accepts the path to our desired partial. Again, AdonisJS already knows to look within resources/views so that should be omitted. Additionally, the @partial tag is self-closing, meaning we do not need to @end it.

@layout()

  <div>
    <h1>Challenges</h1>
    <p>Welcome to the challenges page.</p>

    @include('partials/challenge/available_points')

    @if (!challenges.length)
      <p>No challenges available.</p>
    @else
      <p>Below are our available challenges:</p>
    @endif

    <ul>
      @each(challenge in challenges)
        <li>
          <a href="/challenges/{{ challenge.id }}">
            <h3>{{ challenge.text }}</h3>
            <p>Points: {{ challenge.points }}</p>
          </a>
        </li>
      @endeach
    </ul>
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • index.edge

With that, our page should render the same as before, with the bonus of cleanliness in our page!

Components

Now, unlike partials, components don't share state, but instead have their own isolated state. Meaning, if we wanted to access challenges from our page, we would need to pass it into the component via what's called props. The props we pass into the component are then merged into the isolated state of the component and made available for use. Components do, however, still have access to global and local state!

For this, let's focus on our unordered list by creating a new challenge_grid component.

node ace make:view components/challenge/grid
# DONE:    create resources/views/components/challenge/grid.edge
Copied!

Next, let's move our unordered list into this component.

<ul>
  @each(challenge in challenges)
    <li>
      <a href="/challenges/{{ challenge.id }}">
        <h3>{{ challenge.text }}</h3>
        <p>Points: {{ challenge.points }}</p>
      </a>
    </li>
  @endeach
</ul>
Copied!
  • resources
  • views
  • components
  • challenge
  • grid.edge

Components can be used similarly to partials via a designated @component() tag. This also takes a path to the component from resources/views.

@layout()

  <div>
    <h1>Challenges</h1>
    <p>Welcome to the challenges page.</p>

    @include('partials/challenge/available_points')

    @if (!challenges.length)
      <p>No challenges available.</p>
    @else
      <p>Below are our available challenges:</p>
    @endif

    @component('components/challenge/grid')
    @endcomponent
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • index.edge

Note, unlike partials, components must be ended. More on the reason why in a minute.

Component Props

Since components have an isolated state, we have to pass the data from our page that we want it to have access to as a prop, which can be done via an object as the second argument.

@layout()

  <div>
    <h1>Challenges</h1>
    <p>Welcome to the challenges page.</p>

    @include('partials/challenge/available_points')

    @if (!challenges.length)
      <p>No challenges available.</p>
    @else
      <p>Below are our available challenges:</p>
    @endif

    @component('components/challenge/grid', { challenges })
    @endcomponent
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • index.edge

With that, our component now has access to challenges from the render state of our page and can successfully render out our unordered list! Now, although our component props are merged into the state, we can directly access props as well via $props should we need to. Let's dump this out and take a look!

<ul>
  @each(challenge in challenges)
    <li>
      <a href="/challenge/{{ challenge .id }}">
        <h3>{{ challenge.text }}</h3>
        <p>Points: {{ challenge.points }}</p>
      </a>
    </li>
  @endeach
</ul>

@dump($props)
Copied!
  • resources
  • views
  • components
  • challenge
  • grid.edge

From our dump, we can see this is a ComponentProps object-like structure. Our challenges is a key on this with our array within it, but if we dig into the prototype, we can see this has some utility methods on it as well to get all props, specific props, and more.

ComponentProps {
  challenges: Array:3 [],
  [[Prototype]] {
    all: [function all],
    has: [function has],
    get: [function get],
    only: [function only],
    except: [function except],
    merge: [function merge],
    mergeIf: [function mergeIf],
    mergeUnless: [function mergeUnless],
    toAttrs: [function toAttrs],
  }
}
Copied!

Okay, we can remove that dump, and let's alter our markup here slightly, removing our unordered list and adding a cards class that came with the starter kit. This will display our challenges in a grid with three columns.

<div class="cards">
  @each(challenge in challenges)
    <a href="/challenges/{{ challenge.id }}">
      <h3>{{ challenge.text }}</h3>
      <p>Points: {{ challenge.points }}</p>
    </a>
  @endeach
</div>
Copied!
  • resources
  • views
  • components
  • challenge
  • grid.edge

Next, let's add another component that will be in charge of rendering each challenge within our grid.

node ace make:view components/challenge/grid_item
# DONE:    create resources/views/components/challenge/grid_item.edge
Copied!

We'll move the div from inside our loop into this new component.

<a href="/challenges/{{ challenge.id }}">
  <h3>{{ challenge.text }}</h3>
  <p>Points: {{ challenge.points }}</p>
</a>
Copied!
  • resources
  • views
  • components
  • challenge
  • grid_item.edge

Then, let's add this component within our loop and pass each challenge into it as a prop.

<div class="cards">
  @each(challenge in challenges)
    @component('components/challenge/grid_item', { challenge })
    @endcomponent
  @endeach
</div>
Copied!
  • resources
  • views
  • components
  • challenge
  • grid.edge

Component Slots

A couple of things here. Although the component tag is not self-closing, we can mark it as such by using an exclamation point, like @!component(). With this, we no longer need an @end, so we can remove it.

<div class="cards">
  @each(challenge in challenges)
    @!component('components/challenge/grid_item', { challenge })
  @endeach
</div>
Copied!
  • resources
  • views
  • components
  • challenge
  • grid.edge

Why aren't components self-closing? Because they can utilize slots or child content. We can register a position inside our component for where child contents should go!

<div class="cards">
  @each(challenge in challenges)
    {{{ await $slots.main() }}}
  @endeach
</div>
Copied!
  • resources
  • views
  • components
  • challenge
  • grid.edge

Here, $slots is an object that gets named slots we can utilize within our component. The main slot is the default slot and will always be defined. If this looks familiar, it's because we saw exactly this within our layout! Next, we place our grid item as child content, between the start and end tags, within our grid component, within our index page.

@layout()

  <div>
    <h1>Challenges</h1>
    <p>Welcome to the challenges page.</p>

    @include('partials/challenge/available_points')

    @if (!challenges.length)
      <p>No challenges available.</p>
    @else
      <p>Below are our available challenges:</p>
    @endif

    @component('components/challenge/grid', { challenges })
      @!component('components/challenge/grid_item')
    @endcomponent
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • index.edge

Slot Scopes

The problem here is that we need access to our individual challenge being looped over for our grid item. One option we have is to use slot scopes. This is contextual information we can pass through the slot, via an argument to the slot method, to the child.

<div class="cards">
  @each(challenge in challenges)
    {{{ await $slots.main({ challenge }) }}}
  @endeach
</div>
Copied!
  • resources
  • views
  • components
  • challenge
  • grid.edge

Then, we can use the @slot tag to access this scope for the child.

@layout()

  <div>
    <h1>Challenges</h1>
    <p>Welcome to the challenges page.</p>

    @include('partials/challenge/available_points')

    @if (!challenges.length)
      <p>No challenges available.</p>
    @else
      <p>Below are our available challenges:</p>
    @endif

    @component('components/challenge/grid', { challenges })
      @slot('main', scope)
        @!component('components/challenge/grid_item', { challenge: scope.challenge })
      @endslot
    @endcomponent
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • index.edge

Injected State

We could alternatively skip the middleman, though, and inject the challenge directly from the grid component into the child grid item component.

<div class="cards">
  @each(challenge in challenges)
    @inject({ challenge })
    {{{ await $slots.main() }}}
  @endeach
</div>
Copied!
  • resources
  • views
  • components
  • challenge
  • grid.edge

This injects our challenge into the $context of the child contents for this component. We can then access this $context directly inside our grid item component for use. Since the inject and slot occur for each loop, this will happen once per individual item in our array.


@let(challenge = $context.challenge)
<a href="/challenges/{{ challenge.id }}">
  <h3>{{ challenge.text }}</h3>
  <p>Points: {{ challenge.points }}</p>
</a>
Copied!
  • resources
  • views
  • components
  • challenge
  • grid_item.edge

Lastly, we need to remove the middleman from our index page. If we dump the $context from our index page, it'll make a little more evident exactly what's happening here as well.

@layout()

  <div>
    <h1>Challenges</h1>
    <p>Welcome to the challenges page.</p>

    @include('partials/challenge/available_points')

    @if (!challenges.length)
      <p>No challenges available.</p>
    @else
      <p>Below are our available challenges:</p>
    @endif

    @component('components/challenge/grid', { challenges })
      @dump($context)
      @!component('components/challenge/grid_item')
    @endcomponent
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • index.edge

As you can see, we get three individual dumps, one per item we're looping over! Great, we can remove that dump. The last, and probably cleanest, option is to just loop directly within our page.

@layout()

  <div>
    <h1>Challenges</h1>
    <p>Welcome to the challenges page.</p>

    @include('partials/challenge/available_points')

    @if (!challenges.length)
      <p>No challenges available.</p>
    @else
      <p>Below are our available challenges:</p>
    @endif

    @component('components/challenge/grid')
      @each(challenge in challenges)
        @!component('components/challenge/grid_item', { challenge })
      @endeach
    @endcomponent
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • index.edge
<div class="cards">
  {{{ await $slots.main() }}}
</div>
Copied!
  • resources
  • views
  • components
  • challenge
  • grid.edge
<a href="/challenges/{{ challenge.id }}">
  <h3>{{ challenge.text }}</h3>
  <p>Points: {{ challenge.points }}</p>
</a>
Copied!
  • resources
  • views
  • components
  • challenge
  • grid_item.edge

Component Tags

Next, any component created within the resources/views/components folder automatically gets registered as a tag and can be used as such. This gives us a more convenient way to utilize components and cleaner markup to boot. Imagine the folder structure of the component file as a camel-cased nested JavaScript object. So, challenge/grid_item would be:

const challenge = {
  grid: '...',
  gridItem: '...'
}

challenge.grid
challenge.gridItem
Copied!

That is how we can access the component in tag form! Importantly, note that component tags do not have a corresponding named end tag to go with them. So here, we will just want to use @end to end the component.

@layout()

  <div>
    <h1>Challenges</h1>
    <p>Welcome to the challenges page.</p>

    @include('partials/challenge/available_points')

    @if (!challenges.length)
      <p>No challenges available.</p>
    @else
      <p>Below are our available challenges:</p>
    @endif

    @challenge.grid()
      @each(challenge in challenges)
        @!challenge.gridItem({ challenge })
      @endeach
    @end
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • index.edge

Now, if you're thinking, hey this looks familiar, you're right! What we've just done is exactly how our layout works because the layout itself is a component. If you want to add another layout option, you can create a new component just like we have here.

Small note, if you have a component at resources/views/components/challenge/index.edge, you can access it as a tag with just @challenge(). The index will be implied if omitted!

Join the Discussion 0 comments

Create a free account to join in on the discussion
robot comment bubble

Be the first to comment!