Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

"Add to Cart Controls" Component

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

The 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.

Refactoring to a product-show/ Directory

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!

Creating the Component

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

Adding & Organizing the Props

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.

Adding Data

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.

Leave a comment!

2
Login or Register to join the conversation
Jakub Avatar

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

Reply

Hey @Jakub!

Sorry for my very slow reply! Hmm.

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')

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!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This course is also built to work with Vue 3!

What JavaScript libraries does this tutorial use?

// 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
    }
}
userVoice