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 SubscribeThe marketing department wants us to add a "featured product" to the cart sidebar with the ability to add that item to the cart right from this page.
Let's go look at the product show page for our trusty inflatable sofa. The most complex part of this page - by far - is the "add to cart controls": the quantity box, color selector, button and all the functionality related to those. I'd really like to reuse all of this on the featured product sidebar.
And so, this is a perfect opportunity to isolate this chunk of HTML and functionality into a beautiful re-usable component.
Start inside the assets/components/
directory: create a new folder called product-show/
for organization. Then move the product-show.vue
component into that directory... but rename it to index.vue
so that our existing import statements still work. But... this change does confuse Webpack: it doesn't realize that it should stop looking for product-show.vue
and start looking for product-show/index.vue
.
Ht Control+C on Encore to stop it, and then restart it:
yarn dev-server
Perfect!
Inside product-show/
, create the new file. Let's call it cart-add-controls.vue
. Poetry. Start like we always do: add the template
and the script
with export default
... and a name
key to start. How about ProductCartAddControls
.
<template> | |
... lines 2 - 30 | |
</template> | |
... line 32 | |
<script> | |
... lines 34 - 35 | |
export default { | |
name: 'ProductCartAddControls', | |
... lines 38 - 40 | |
}; | |
</script> |
For the template... let me first close a few old files so we can focus. Much better! Open index.vue
: the component for the product show page. Up in the template, copy the entire div that surrounds all of these controls and replace it with a nice TODO.
<template> | |
<div> | |
... lines 3 - 8 | |
<div | |
... lines 10 - 12 | |
> | |
... lines 14 - 30 | |
<div class="col-8 p-3"> | |
... lines 32 - 33 | |
<div class="row mt-4 align-items-center"> | |
... lines 35 - 38 | |
<div class="col-8 p-3"> | |
TODO | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</template> | |
... lines 47 - 120 |
Paste this into the new component:
<template> | |
<div class="d-flex align-items-center justify-content-center"> | |
<color-selector | |
v-if="product.colors.length !== 0" | |
@color-selected="updateSelectedColor" | |
/> | |
<input | |
v-model.number="quantity" | |
class="form-control mx-3" | |
type="number" | |
min="1" | |
> | |
<button | |
class="btn btn-info btn-sm" | |
:disabled="cart === null" | |
@click="addToCart" | |
> | |
Add to Cart | |
<i | |
v-show="addToCartLoading" | |
class="fas fa-spinner fa-spin" | |
/> | |
<i | |
v-show="addToCartSuccess" | |
class="fas fa-check" | |
/> | |
</button> | |
</div> | |
</template> | |
... lines 32 - 43 |
Let's see: this uses the color-selector
component, so we need to import that. Copy the import from the old file, delete it and delete the key from components
. We can also remove this old, unused import.
Back in the new component, paste the import, add a components
key and put ColorSelector
inside.
... lines 1 - 32 | |
<script> | |
import ColorSelector from '@/components/color-selector'; | |
export default { | |
name: 'ProductCartAddControls', | |
components: { | |
ColorSelector, | |
}, | |
}; | |
</script> |
Fixed! Back up in the template, let's see what else we need. Ah yes! The original component had some styling for an input
element: this was for the quantity input. Copy that and delete it. In the new component, at the bottom, add the usual <style lang="scss" module>
.
... lines 1 - 43 | |
<style lang="scss" module> | |
... lines 45 - 49 | |
</style> |
Then create a .component
class with the :global
pseudo-selector, though that's not important yet: it would just help remove excessive modular class prefixes if we later add nested CSS classes under .component
. Paste the input
styling inside.
<template> | |
<div :class="[$style.component, 'd-flex', 'align-items-center', 'justify-content-center']"> | |
... lines 3 - 43 | |
<style lang="scss" module> | |
.component :global { | |
input { | |
width: 60px; | |
} | |
} | |
</style> |
Finally, for this to work, we need to add that component
class to the top level element. Refactor to use :class
, set it to an array and surround the existing classes with quotes. Oh, whoops! I forgot to separate d-flex
and align-items-center
. That won't cause any issues... but make sure to separate them in your app. Add $style.component
.
<template> | |
<div :class="[$style.component, 'd-flex', 'align-items-center', 'justify-content-center']"> | |
... lines 3 - 29 | |
</div> | |
</template> | |
... lines 32 - 51 |
Ok! Now we're ready to get to the interesting stuff. Normally, if you want to make some code reusable in PHP or JavaScript, you isolate it into a function. Then, you usually need to add some arguments so that whoever calls that function can pass information it needs and control its behavior.
The same is true when we create a reusable Vue component, except that instead of adding function arguments, we add component props.
When I look at the template, I can see three props that we need to pass: the product
these controls are for and also addToCartLoading
and addToCartSuccess
... because this component will not be responsible for actually saving the new item to the cart. And so, it needs to be passed these props to know if the cart AJAX call is currently happening or if it just finished successfully.
Scroll down to the component. Add props
... and I'm going to paste those 3 props: product
is an object, the other two are booleans and all are required.
... lines 1 - 32 | |
<script> | |
... lines 34 - 35 | |
export default { | |
name: 'ProductCartAddControls', | |
... lines 38 - 40 | |
props: { | |
product: { | |
type: Object, | |
required: true, | |
}, | |
... lines 46 - 49 | |
addToCartLoading: { | |
type: Boolean, | |
required: true, | |
}, | |
addToCartSuccess: { | |
type: Boolean, | |
required: true, | |
}, | |
}, | |
}; | |
</script> | |
... lines 61 - 69 |
Back up in the template, PhpStorm is a bit happier thanks to those new props, but we are still referencing a few other undefined things.
One of them is cart
. We use this in exactly one spot: to figure out if the button should be disabled because the cart is still loading. So we could add a cart
prop... it would be our way of saying:
Hey! You need to pass the
cart
to us so we configure out whether or not the the button should be disabled!
But... do we really need the entire cart object? Nope: we just need to know whether or not the user should be allowed to add the item to the cart. So instead of forcing the entire cart
object to be passed as a prop, let's add a simpler prop. Copy addToCartLoading
, paste and call this one allowAddToCart
.
... lines 1 - 32 | |
<script> | |
... lines 34 - 35 | |
export default { | |
name: 'ProductCartAddControls', | |
... lines 38 - 40 | |
props: { | |
... lines 42 - 45 | |
allowAddToCart: { | |
type: Boolean, | |
required: true, | |
}, | |
... lines 50 - 57 | |
}, | |
}; | |
</script> | |
... lines 61 - 69 |
Now, on the button, it should be disabled if not allowAddToCart
.
<template> | |
<div :class="[$style.component, 'd-flex', 'align-items-center', 'justify-content-center']"> | |
... lines 3 - 14 | |
<button | |
... line 16 | |
:disabled="!allowAddToCart" | |
... line 18 | |
> | |
... lines 20 - 28 | |
</button> | |
</div> | |
</template> | |
... lines 32 - 69 |
Awesome! Another way to explain why I'm using an allowAddToCart
prop instead of a prop for the entire cart
is that I want my component to be as dumb as possible. The dumber it is, the more control we have over it. Each time we use this component, we can control whether the button is disabled using whatever logic we need.
Ok: the template is still referencing one more variable: quantity
on v-model
. In addition to that, the user will also select a color via the color-selector
component. When that happens, we're currently calling an updateSelectedColor
method, which still lives in the old component. Its job was to set a selectedColorId
data.
If you think about it: both the quantity
and the selectedColorId
will need to be stored as data inside the new, reusable component: we need to keep track of both values so that we have them handy when the "add to cart" button is clicked.
In index.vue
, find data
, copy those two keys and delete them:
... lines 1 - 47 | |
<script> | |
... lines 49 - 54 | |
export default { | |
name: 'ProductShow', | |
... lines 57 - 67 | |
data() { | |
return { | |
product: null, | |
loading: true, | |
}; | |
}, | |
... lines 74 - 98 | |
}; | |
</script> | |
... lines 101 - 114 |
Back in the new component, add the data
function and return the two keys:
... lines 1 - 32 | |
<script> | |
... lines 34 - 35 | |
export default { | |
name: 'ProductCartAddControls', | |
... lines 38 - 58 | |
data() { | |
return { | |
quantity: 1, | |
selectedColorId: null, | |
}; | |
}, | |
}; | |
</script> | |
... lines 67 - 75 |
Alright! Our template is still referencing some undefined methods... but I think we should at least get this to render!
Next, let's use this component from index.vue
and finish the last pieces: implementing the missing methods as... methods... or by emitting an event.
Hey @Jakub!
Sorry for my very slow reply! Hmm.
I've also had to change import in
/pages/products.vue
component (fromimport ProductShow from '@/components/product-show/product-show'
toimport ProductShow from '@/components/product-show'
)
When I look at our /pages/products.vue
- e.g. here https://symfonycasts.com/screencast/vue2/dynamic-component#codeblock-6f1d66b01c - it looks like this:
import ProductShow from '@/components/product-show';
Unless I changed it later and I can't remember, I don't think it ever looked like import ProductShow from '@/components/product-show/product-show
. So this was, maybe, just something that you did slightly differently, which is why you needed to make that extra change :).
Cheers!
// package.json
{
"devDependencies": {
"@fortawesome/fontawesome-free": "^5.15.1", // 5.15.1
"@symfony/webpack-encore": "^0.30.0", // 0.30.2
"axios": "^0.19.2", // 0.19.2
"bootstrap": "^4.4.1", // 4.5.3
"core-js": "^3.0.0", // 3.6.5
"eslint": "^6.7.2", // 6.8.0
"eslint-config-airbnb-base": "^14.0.0", // 14.2.0
"eslint-plugin-import": "^2.19.1", // 2.22.1
"eslint-plugin-vue": "^6.0.1", // 6.2.2
"regenerator-runtime": "^0.13.2", // 0.13.7
"sass": "^1.29.0", // 1.29.0
"sass-loader": "^8.0.0", // 8.0.2
"vue": "^2.6.11", // 2.6.12
"vue-loader": "^15.9.1", // 15.9.4
"vue-template-compiler": "^2.6.11", // 2.6.12
"webpack-notifier": "^1.6.0" // 1.8.0
}
}
Hi,
I've had an issue during moving 'product-show.vue' component to 'product-show' directory and renaming it to 'index.vue'. For me it was not enough just to restart Encore. I've also had to change import in '/pages/products.vue' component (from "import ProductShow from '@/components/product-show/product-show'" to "import ProductShow from '@/components/product-show'") and change import for light-component.scss in 'index.vue' (I don't know why but it have missed full path after move operation)
Cheers,
Jakub