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.edgeCopied!
@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> @endCopied!
- 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.edgeCopied!
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> @endCopied!
- 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> @endCopied!
- 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.edgeCopied!
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> @endCopied!
- 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> @endCopied!
- 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> @endCopied!
- 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> @endCopied!
- 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.gridItemCopied!
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> @endCopied!
- 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!