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 SubscribeLet's really make these products come to life! Now that we have a component whose only job is to render a single product, this is going to be fun & clean.
I'll start by pasting some HTML into the template: you can copy this from the code block on this page. But there's nothing too interesting yet. We are referencing a few styles - $style['product-box']
and $style.image
and we'll add a style
tag soon for those. If you're wondering why I'm using the square bracket syntax, that's because JavaScript doesn't like dashes with the object property syntax: you can't say $style.product-box
... which, yes, is annoying.
<template> | |
<div class="col-xs-12 col-6 mb-2 pb-2"> | |
<div :class="$style['product-box']"> | |
<div :class="$style.image"> | |
<img | |
:alt="product.name" | |
:src="product.image" | |
class="d-block mb-2" | |
> | |
<h3 class="font-weight-bold mb-2 px-2"> | |
{{ product.name }} | |
</h3> | |
</div> | |
<div class="p-2 my-3 d-md-flex justify-content-between"> | |
<p class="p-0 d-inline"> | |
<strong>${{ product.price }}</strong> | |
</p> | |
<button | |
class="btn btn-info btn-sm" | |
> | |
View Product | |
</button> | |
</div> | |
</div> | |
<hr> | |
<div class="px-2 pb-2"> | |
<small>brought to you by {{ product.brand }}</small> | |
</div> | |
</div> | |
</template> | |
... lines 34 - 46 |
A little below this, we are using some product data. If you go back to the Vue dev tools and click on Catalog, each product has several fields on it, like brand
, image
- which is the URL to an image - name
, price
, and even stockQuantity
.
So, for the image, we're using src="product.image"
- with the :
that makes this attribute dynamic - and we're rendering more data for the alt
attribute, the product name and the product.price
. We also have a button to view the product page... which isn't doing anything yet... then we print product.brand
.
Hopefully this all feels pretty simple.
If we move over now and check the console, ooooo:
Cannot read property
product-box
of undefined
Coming from product-card
. Vue is telling us that the $style
variable is undefined... which makes sense: we don't have a style
tag yet! No problem: add the <style>
with lang="scss"
. In fact, $style
will be undefined until you have a style
tag and that tag has the module
attribute.
... lines 1 - 46 | |
<style lang="scss" module> | |
... lines 48 - 65 | |
</style> |
For the styles itself, I'm going to import this scss/components/light-component.scss
file, which is a Sass mixin. Add @import
, then, to use the Webpack alias we created earlier, say ~
and the name of the alias. So ~styles/
to point to the scss
directory - then components/light-component
.
... lines 1 - 46 | |
<style lang="scss" module> | |
@import '~styles/components/light-component'; | |
... lines 49 - 65 | |
</style> |
Excellent! Now that we've done the hard work, I'll paste in a few more styles. This adds the .product-box
and .image
classes that correspond with the $style
code in the template.
... lines 1 - 46 | |
<style lang="scss" module> | |
... line 48 | |
.product-box { | |
border: 1px solid $light-component-border; | |
box-shadow: 0 0 7px 4px #efefee; | |
border-radius: 5px; | |
} | |
.image { | |
img { | |
width: 100%; | |
height: auto; | |
border-top-left-radius: 5px; | |
border-top-right-radius: 5px; | |
} | |
h3 { | |
font-size: 1.2rem; | |
} | |
} | |
</style> |
Ok, I think we're ready! When we move over... hmm... I don't see any products. Let's refresh to be sure. And... yes! There they are! Each has an image, title, price and button.
But, hmm: these prices aren't right. I would love to be able to sell blank CDs for $1,300... but that's not the real price. When you deal with prices, it's pretty common to store the prices in "cents", or whatever the lowest denomination of your currency is. The point is, this is 2,300 cents, so $23.
Yep, we have a formatting problem: we need to take this number, divide it by 100 and put the decimal place in the right spot. Yes, we have a situation where we need to render something that's based on a prop, but needs some processing first. Does that ring a bell? That's the perfect use-case for one of my absolute favorite features of Vue: computed properties!
Let's do this! Add a computed
option with one computed property called price()
. Inside, return this.product.price
- to reference the price of the product
prop
and divide this by 100. Good start! To convert this into a string that always has two decimal points, we can use a fun JavaScript function that exists on any Number: .toLocaleString()
. Pass this the locale - en-US
or anything else - and then an options array with minimumFractionDigits: 2
.
... lines 1 - 34 | |
<script> | |
export default { | |
... lines 37 - 43 | |
computed: { | |
... lines 45 - 48 | |
price() { | |
return (this.product.price / 100) | |
.toLocaleString('en-US', { minimumFractionDigits: 2 }); | |
}, | |
}, | |
}; | |
</script> | |
... lines 56 - 77 |
Pretty cool, right? I'll even add some docs to our function. I'm over-achieving!
... lines 1 - 43 | |
computed: { | |
/** | |
* Returns a formatted price for the product | |
* @returns {string} | |
*/ | |
price() { | |
... lines 50 - 51 | |
}, | |
}, | |
... lines 54 - 77 |
Now that we have a computed property called price
, we can use it with {{ price }}
, as if price
were a prop or data.
<template> | |
... lines 2 - 15 | |
<div class="p-2 my-3 d-md-flex justify-content-between"> | |
<p class="p-0 d-inline"> | |
<strong>${{ price }}</strong> | |
</p> | |
... lines 20 - 25 | |
</div> | |
... lines 27 - 32 | |
</template> | |
... lines 34 - 77 |
We know that computed properties - similar to props
and data
- are added as properties to the Vue instance, which is why we can say things like this.price
. But behind the scenes, when we access this property, it will call our method. As a bonus, it even caches that property in case we refer to it multiple times.
Oh, and by the way: this is one of the reasons why I created a specific component for rendering each product. If we did not have this component... and we were rendering this data inside the ProductList
component, we wouldn't be able to use a computed property... because we would need to pass an argument: the product whose price we need to calculate. Instead, we would have needed to create a method... which isn't the end of the world, but is less efficient. Any time that you're creating a method to return data, it's a signal that you should considering refactoring into a smaller component that could use a computed property.
Anyways, now when we move over... we don't even need to refresh: there is our beautiful 30.00 price. What a bargain!
Before we keep going, I want to circle back on a controversial decision I made earlier: the fact that we kept the products
data inside catalog.vue
even though the product-list
component is technically the deepest component that needs it.
If you look at catalog.vue
, it holds the Ajax call and pretty soon it will hold logic for a search bar. But... it doesn't render a lot of markup. I mean, yeah, it has an <h1>
up here and a <div>
down here, but its main job is to contain data and logic.
Compare this to the product-list component: index.vue
. This doesn't have any logic! It receives props and renders.
Well... surprise! This separation was not an accident: it's a design pattern that's often followed in Vue and React. It's called smart versus dumb components, or container versus presentational components.
This pattern says that you should try to organize some components to be smart - components that make Ajax calls and change state - and other components to be dumb - that receive props, render HTML and maybe emit an event when the user does something.
product-card
is another example of a dumb, or "presentational" component. Sure, it has a computed property to do some basic data manipulation, but this is just a component that receives a prop and renders, maybe with some minor data formatting.
To compare this to the Symfony world, one way to think about this is that a smart component is like a controller: it does all the work of getting the data ready. That might involve calling other services, but that's not important. Once it has all the data, it passes it into a template, which is like a dumb component. The template simply receives the data and renders it.
Like all design patterns, keep this in the back of your mind as a guide, but don't obsess over it. We're doing a good job of making this separation in some places, but we're not perfect either, and I think that's great. However, if you can generally follow this, you'll be happier with your components.
Next, now that we're loading data via Ajax, we need a way to tell the user that things are loading... not that our server is on fire and they're waiting for nothing. Let's create a Loading component that we can re-use anywhere.
Hey, I have gotten this far into the tutorial but must have done something that created a show-stopping problem that I don't know how to fix. When I start the server and try to load the index page, I get a fatal
PHP Fatal error: Uncaught ReflectionException: Class "Doctrine\Common\Cache\ArrayCache" does not exist in /opt/www/symfony/vue-symfony/vendor/symfony/dependency-injection/ContainerBuilder.php:1089<br />
and similar whenever I try to do something like composer update
or symfony console cache::clear
My research has come up with things like <a href="https://stackoverflow.com/questions/68652105/composer-install-update-trigger-class-doctrine-common-cache-arraycache-does-not ">https://stackoverflow.com/questions/68652105/composer-install-update-trigger-class-doctrine-common-cache-arraycache-does-not </a> but before I try their advice I thought I'd try asking here.
Any ideas? Thanks.
Hey David,
Hm, not sure, just a fast wild idea - could you try to clear the cache by removing the folder? i.e. execute "rm -rf var/cache" in your console. Does the problem still exist?
Cheers!
Yeah, I thought of that and tried it, but it didn't work -- same result. (Odd, because you might think if its complaint is that it can't clear the cache so you do it yourself, it might stop complaining, at least temporarily. But no.) So I went ahead and tried composer require doctrine/cache "^1.12"
and that <i>seems</i> to work -- don't know if bad side effects will show up somewhere later on.
If I understand it correctly, this ArrayCache
class is intended as nothing more than a sort of no-op, a placeholder for the dev environment, and doesn't actually cache anything in the sense of persisting from one request to the next. Kind of ironic to get stuck there. I know you guys have more than enough to do, but maybe if there's a future version of this tutorial, this would be something to take a look at.
Hey davidmintz
The doctrine/cache
library was deprecated some time ago. I'm not sure how you ran into this problem but I'm guessing you upgraded the DoctrineBundle library. Try upgrading all doctrine libraries by running
composer upgrade "doctrine/*"
and clear the cache manually just in case rm -rf var/cache
Yes, I think I did mess around with composer and got myself into this. I will try this suggestion once I work up the nerve -- it will probably work, but I don't have a deep understanding of dependency management and hesitate to play around now that I have it working :-) Thanks!
Hi there
Thanks a lot for the awesome tutorials!!
One question, I have the same entity fields like you in the tutorial. In my database I see the column "image_filename" with "pen.png" etc.. But in the Vue inspector the product object has all fields like "name", but the "image_filename" is missing. Have you an idea whats going on?
Beste regards
Michael
Hey Michael! My first guess would be a problem with API platform itself! If clearing the symfony cache doesn't fix it, then may be you have made some changes to the Entities yourself and API platform is not exposing that particular field? Let me know if any of this makes sense!
Hey Matias
I've copied 1:1 the entity code of the tutorial and cleared the cache. Also when I create a new field in the entities the API platform doesn't show this field. But it's only my testing app. Now I build my first app with the API platform and I hope there it works fine. ;-)
Hey Michael K.!
Let's see if we can get this figured out :).
> I've copied 1:1 the entity code of the tutorial and cleared the cache
That *is* strange.
> Also when I create a new field in the entities the API platform doesn't show this field
So the logic for whether or not a field shows up in API Platform is "fairly" simple (with quotes around it, because nothing in programming is always *that* simple). There are 2 big things to look for:
A) Does your field have a "getter" method? The naming is important here. If your property is called imageFilename, then you need a getImageFilename() method. If you actually called your *property* image_filename, then I think it needs to be getImage_filename(), but I don't recommend naming things like this.
B) Your property needs to have the @Group annotation above it with a group that matches the "groups" option of the normalizationContext on your entity (if you have one, which we do in our project).
Let me know if this helps! And, of course, check out our API Platform tutorials to learn a ton more ;).
Cheers!
Sometimes it's that easy. In your course code is the @Group annotation missing and i didn't saw it.
`/**
* @ORM\Column(type="string", length=255)
*/
private $imageFilename;`
And I found another little diffrent between tutorial and your course code::src="product.image"
but in the entity the field has the name $imageFilename.
Thank you so much for your help!
Best regards!
Hey Michael K.!
Ah, I understand better! There's not an inconsistency with the course download and the video - they use the exact same code. But I forgot that I exposed the "image" field in a special way. Instead of exposing imageFilename, I used a custom ProductNormalizer class (you can see this if you download the course code), which added a custom "image" field, with the full URL to the image. I bet you're just missing this ProductNormalizer in your code, which is why you weren't seeing the field!
On an API Platform-level, both using Groups or a custom normalizer are valid way to expose a field (the Groups way is the more official way). I used a custom normalizer so that I could use a service to help generate the "image" value.
Cheers!
At 57 seconds into this video you show all the different properties in the Dev Tools for a product. One of them that isn't talked about is colors. How would you go about accessing colors? Do you have to make another AJAX call or is it available?
Hi Brandon!
In the first episodes of Part 2 of this tutorial (To be released), we will be talking about the Product Page, which takes care of the colors!
If you want to jump ahead and play around with it, we basically fetch all colors in the database with a colors service (you can fiddle with API Platform to see which API call is needed for that) and then display the color choices with a simple color swatches selector.
Stay tuned!
FYI: The "Velvis" product image 404s on case-sensitive filesystems. This is caused by a "V" in AppFixtures.php on line 130 for the image name instead a "v" as it is in the filename.
Hey Justin!
Silly Mac! Letting me make mistakes like this! Thanks for letting me know - I totally see it! We can't have anyone missing the velvis photo ;)
I've just pushed up a fix to the course code (lowercase "v" in AppFixtures). Thanks for the report - I really appreciate it!
Cheers!
"In fact, $style
will be undefined until you have a style tag and that tag has the module attribute."
It is still undefined even if you have the style tag and the module attribute but within style you don't have any styles.
That is correct, Todor! Thanks for pointing that out!
If the Style tag is empty, there will not be a $style object for us to use. As soon as we enter anything in our modular style block, $style will become available!
// 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
}
}
Somebody at SymfonyCasts is a comedic genius! These products are hilarious. I want to buy the Velvis!