If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeIn our sidebar, we're looping over the categories and creating a link for each one to /category/
plus the category id
. When we did this, I said that this would link to a future page that we would create. Well... guess what! If you click on office supplies
, this... actually works! Well, sort of: the URL changed but... it looks like it just loaded the same page.
Let me show you what's going on: I've done a little bit of work behind the scenes. In src/Controller/ProductController.php
, we have an app_homepage
route that renders a product/index.html.twig
template. This is the page that we've been using so far. Open templates/product/index.html.twig
so we can see it. Nothing special here: we have our target div
for the Vue app, and the script
and link
tags for our products
Webpack entry.
Head back to the controller. Below this, we have another route called app_category
. That's why this page works! I've already created a route and controller for /category/{id}
that loads the same Twig template as our other page!
So here's the idea: we're purposely not building a single page application. Part of the reason is that multi page applications are, in a lot of ways, trickier to work with in Vue than single page apps. And also, it's totally legal to have a traditional multi page application with Vue mixed in only where you need it.
In this app, we're going to have a homepage - which is basically the All Products
category - where you can click on any category to go to a totally different page. That page will render the same Vue app, but only show products for that category. And in a future tutorial, we're also going to create a "product page" that will do the same thing: be a separate URL that's handled by the same Vue app.
So yes: our one Vue app will behave differently based on which page we're on.
To achieve that, when we're on a specific category page, our Vue code needs to do two special things.
One: we're probably going to want to highlight which category we're on in the sidebar so that the user knows that we're on the office supplies
category and not in All Products
, for example.
And two: we're going to need to filter the product list because, right now, no matter what category I click, I am always getting the same list of products. We need to somehow realize that we are on a category page and then use that information to make an API request for only products from that category.
How can we do that? I have no idea! I'm kidding! In some ways, it's a simple problem: the one piece of information that we need to know in our Vue code is what category ID we are currently on. Are we on no category ID? Meaning: show all products? Or are we on category ID 23?
To do that, we need to communicate this information from the server to Vue. There are multiple ways to do this, but my favorite approach is to set a global JavaScript variable in the template and then read that from inside of our Vue app.
Start by adding a second argument to the showCategory()
action. Don't worry, we're not going to go too much into Symfony and I'll explain what I'm doing along the way. Add IriConverterInterface $iriConverter
to get a service from API Platform.
... lines 1 - 5 | |
use ApiPlatform\Core\Api\IriConverterInterface; | |
... lines 7 - 11 | |
class ProductController extends AbstractController | |
{ | |
... lines 15 - 25 | |
public function showCategory(Category $category, IriConverterInterface $iriConverter): Response | |
... lines 27 - 39 | |
} |
Remember: when you click on the Catalog
component in the Vue Dev tools and look at the products
data, each item that we get back from our API - whether it's a product, category or something else - has an @id
property. That is known as the IRI. I like to use this IRI string instead of the integer ID from the database because it's more useful: it's a real, functional URL!
So on the category page, instead of setting a JavaScript variable that says we're on category 23
, I'm going to use the IRI of that category. This $iriConverter
will help me get that.
Add a second argument to the template called currentCategoryId
- in reality, this is the "current category IRI" - set to $iriConverter->getIriFromItem()
and then pass the $category
object.
... lines 1 - 25 | |
public function showCategory(Category $category, IriConverterInterface $iriConverter): Response | |
{ | |
return $this->render('product/index.html.twig', [ | |
'currentCategoryId' => $iriConverter->getIriFromItem($category), | |
]); | |
} | |
... lines 32 - 41 |
Obviously, we're going to use currentCategoryId
in the template. But when we do that, we need to be careful: there are two pages that render the same template... and the currentCategoryId
variable will only be available on one of those pages, not both.
In index.html.twig
, above where I'm rendering my products
JavaScript, add a script
tag and say {%
if currentCategoryId is defined %} with {% else %}
and {% endif %}
. When the variable is defined, use it to set a global JavaScript variable: window.currentCategoryId =
, a set of quotes, and then {{ currentCategoryId }}
.
That's it! Oh, but in theory, if currentCategoryId
contained a single quote, this would break. To be extra safe, pipe this to e('js')
.
... lines 1 - 12 | |
{% block javascripts %} | |
... lines 14 - 15 | |
<script> | |
{% if currentCategoryId is defined %} | |
window.currentCategoryId = '{{ currentCategoryId|e('js') }}'; | |
{% else %} | |
... line 20 | |
{% endif %} | |
</script> | |
... lines 23 - 24 | |
{% endblock %} |
That will escape the string so that it's always safe for JavaScript. In the else
, if no current category is set, that means we're on the homepage. Let's say window.currentCategoryId = null;
... lines 1 - 16 | |
{% if currentCategoryId is defined %} | |
... line 18 | |
{% else %} | |
window.currentCategoryId = null; | |
{% endif %} | |
... lines 22 - 26 |
The end result of this long journey is that when we refresh the page, view the source and scroll down to where the JavaScript is... yes! We have window.currentCategoryId = '/api/categories/23'
. The back slashes are just escaping the forward slashes.
Next, let's use this global variable in our Vue code to highlight which category we're on in the sidebar.
Hey Numan A.
Yes, you're right. That bundle is required if you want to leverage the ParamConverter feature on your routes. Thanks for pointing it out
Cheers!
I'm noticing (in the video) the dreaded Flash Of Unstyled Content, a/k/a FOUC. What's causing that and how to we fix it? (I know in general terms it happens when the HTML is rendered before the CSS and Javascript do their thing, but I'm wondering what's going on in this specific case.)
Hey David,
I'm not sure I understand, are you referring to the specific point in timeline of the video?
Cheers!
Hey, Ryan!
I think your solution with global variable is ugly.
I used HTML data attribute<a href="https://ibb.co/nswZj3z"> for server vars.</a>
What do you think about it?
{#templates/product/index.html.twig#}
{% extends 'base.html.twig' %}
{% block body %}
<div id="app" data-current-category-id="{{ currentCategoryId|default(null) }}"></div>
{% endblock %}
// assets/js/products.js
import { createApp } from 'vue';
import App from '@/pages/products';
const mountEl = document.querySelector('#app');
createApp(App, { ...mountEl.dataset }).mount('#app');
//assets/js/pages/products.vue
mounted() {
this.currentCategoryId = this.$attrs.currentCategoryId || null;
},
Hey Evgeniy,
I don't think that's ugly, it's still a valid case. But yes, there's another way you can pass data to your JS code using data attributes, and this is also good. And I bet we also mention this way in our tutorials, maybe not in this one, but in others - definitely. Feel free to use any of these solutions. But from the performance point of view, using global vars might be more effective, though I'm not sure 100%.
Cheers!
// package.json
{
"devDependencies": {
"@symfony/webpack-encore": "^0.30.0", // 0.30.2
"axios": "^0.19.2", // 0.19.2
"bootstrap": "^4.4.1", // 4.5.0
"core-js": "^3.0.0", // 3.6.5
"eslint": "^6.7.2", // 6.8.0
"eslint-config-airbnb-base": "^14.0.0", // 14.1.0
"eslint-plugin-import": "^2.19.1", // 2.20.2
"eslint-plugin-vue": "^6.0.1", // 6.2.2
"regenerator-runtime": "^0.13.2", // 0.13.5
"sass": "^1.29.0", // 1.29.0
"sass-loader": "^8.0.0", // 8.0.2
"vue": "^2.6.11", // 2.6.11
"vue-loader": "^15.9.1", // 15.9.2
"vue-template-compiler": "^2.6.11", // 2.6.11
"webpack-notifier": "^1.6.0" // 1.8.0
}
}
If are building a similar app from scratch then you will need
composer require sensio/framework-extra-bundle
or your link /category/id will not work you will get "service not exists error" <3