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 SubscribeWhen we add something to the cart, the last piece of data that's hardcoded is the color. Now, some products come in multiple colors and others don't. But when we are on a product with multiple colors - like our inflatable sofa - we need to make sure that we send the selected color's IRI with the AJAX call.
Doing this is... pretty similar to how we handled the quantity. In that case, we had an input
. When the input changes, we update this quantity
data via v-model
:
<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"> | |
... lines 40 - 44 | |
<input | |
v-model.number="quantity" | |
... lines 47 - 49 | |
> | |
... lines 51 - 66 | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</template> | |
... lines 74 - 158 |
Then, down here, it was very easy to reference this.quantity
when adding the item to the cart:
... lines 1 - 74 | |
<script> | |
... lines 76 - 82 | |
export default { | |
name: 'ProductShow', | |
... lines 85 - 95 | |
data() { | |
return { | |
... line 98 | |
quantity: 1, | |
... lines 100 - 103 | |
}; | |
}, | |
... lines 106 - 125 | |
methods: { | |
async addToCart() { | |
... lines 128 - 129 | |
await addItemToCart(this.cart, { | |
... lines 131 - 132 | |
quantity: this.quantity, | |
}); | |
... lines 135 - 136 | |
}, | |
}, | |
}; | |
</script> | |
... lines 141 - 158 |
For the color, we already have this cool color selector component. It lives in assets/components/color-selector.vue
. The important thing to know for us is that whenever we select a color, this component emits a color-selected
event and sends the IRI of that color as the event data.
... lines 1 - 13 | |
<script> | |
... lines 15 - 16 | |
export default { | |
name: 'ColorSelector', | |
... lines 19 - 27 | |
methods: { | |
selectColor(iri) { | |
this.selectedIRI = iri; | |
this.$emit('color-selected', iri); | |
}, | |
}, | |
}; | |
</script> | |
... lines 36 - 56 |
So... we can use that! In product-show.vue
, scroll up to the template and find the color selector. There it is. We've already made this only render if the product does come in multiple colors. If a product doesn't have multiple colors, the colors
array is empty. To listen to the event, add @color-selected
set to a new method: how about updateSelectedColor
.
<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"> | |
<div class="d-flex align-items-center justify-content-center"> | |
<color-selector | |
v-if="product.colors.length !== 0" | |
@color-selected="updateSelectedColor" | |
/> | |
... lines 45 - 67 | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</template> | |
... lines 75 - 169 |
Next, copy the method name and scroll down to add a new piece of data: selectedColorId
set to null:
... lines 1 - 75 | |
<script> | |
... lines 77 - 83 | |
export default { | |
name: 'ProductShow', | |
... lines 86 - 96 | |
data() { | |
return { | |
... lines 99 - 100 | |
selectedColorId: null, | |
... lines 102 - 105 | |
}; | |
}, | |
... lines 108 - 149 | |
}; | |
</script> | |
... lines 152 - 169 |
Under methods
paste updateSelectedColor()
. And because the event we're listening to sends the iri
as its data, this will receive an iri
argument:
... lines 1 - 75 | |
<script> | |
... lines 77 - 83 | |
export default { | |
name: 'ProductShow', | |
... lines 86 - 96 | |
data() { | |
return { | |
... lines 99 - 100 | |
selectedColorId: null, | |
... lines 102 - 105 | |
}; | |
}, | |
... lines 108 - 127 | |
methods: { | |
... lines 129 - 145 | |
updateSelectedColor(iri) { | |
... line 147 | |
}, | |
}, | |
}; | |
</script> | |
... lines 152 - 169 |
Inside... this.selectedColorId = iri
!
... lines 1 - 75 | |
<script> | |
... lines 77 - 83 | |
export default { | |
name: 'ProductShow', | |
... lines 86 - 96 | |
data() { | |
return { | |
... lines 99 - 100 | |
selectedColorId: null, | |
... lines 102 - 105 | |
}; | |
}, | |
... lines 108 - 127 | |
methods: { | |
... lines 129 - 145 | |
updateSelectedColor(iri) { | |
this.selectedColorId = iri; | |
}, | |
}, | |
}; | |
</script> | |
... lines 152 - 169 |
By the way, later in the tutorial, we'll learn how we could have written the color-selector
component in a way that would allow us to use v-model
on it, instead of listening to an event and creating this method. Yep, v-model
isn't just for real form inputs: it can also be used for custom components.
Anyways, up in addToCart()
, change to use color: this.selectedColorId
. Because this defaults to null
, if a product doesn't come in multiple colors, this will still be null, and everyone will be happy.
... lines 1 - 83 | |
export default { | |
name: 'ProductShow', | |
... lines 86 - 127 | |
methods: { | |
async addToCart() { | |
... lines 130 - 136 | |
await addItemToCart(this.cart, { | |
product: this.product['@id'], | |
color: this.selectedColorId, | |
quantity: this.quantity, | |
}); | |
... lines 142 - 143 | |
}, | |
... lines 145 - 148 | |
}, | |
}; | |
</script> | |
... lines 152 - 169 |
Oh, except we need to make sure that if the product does have a color, that the user selects a color before adding the item. We can do that right here: if this.product.colors.length
- so if this product comes in multiple colors - and this.selectedColorId === null
, we have a problem!
... lines 1 - 83 | |
export default { | |
name: 'ProductShow', | |
... lines 86 - 127 | |
methods: { | |
async addToCart() { | |
if (this.product.colors.length && this.selectedColorId === null) { | |
... lines 131 - 132 | |
} | |
... lines 134 - 143 | |
}, | |
... lines 145 - 148 | |
}, | |
}; | |
</script> | |
... lines 152 - 169 |
For now, I'm just going to alert('Please select a color first')
... and return.
... lines 1 - 83 | |
export default { | |
name: 'ProductShow', | |
... lines 86 - 127 | |
methods: { | |
async addToCart() { | |
if (this.product.colors.length && this.selectedColorId === null) { | |
alert('Please select a color first!'); | |
return; | |
} | |
... lines 134 - 143 | |
}, | |
... lines 145 - 148 | |
}, | |
}; | |
</script> | |
... lines 152 - 169 |
That's not a great user experience, but it's good enough for us. Solving this correctly wouldn't be much more work: I'd create a new piece of data - like addToCartError
- set that here, and render it above.
Ok: let's try this thing. Move over and... I'll refresh just to be safe. Click "Add to Cart". Alert!
Ok, we have 15 items in the cart now. Select green, quantity 1, "Add to Cart" and... it looks like it worked! Let's refresh. Yep! The cart is up to 16. In the Vue dev tools, find the cart
. Two items. And... this has quantity
1 with a color set.
At this point. I'm pretty happy with our "add to cart" feature. Well, happy except for one detail: I don't like that the shopping cart count in the header doesn't update until I refresh the page.
But... what can we do? That isn't inside of our Vue app!
The answer is... who cares? What I mean is, if it's important for us to update this count for a good user experience, we can totally do it using good, boring JavaScript.
Open the template that holds this: templates/base.html.twig
. I'll search for "shopping cart". Here it is: line 44. To make it easy to find this span
in JavaScript, add an id="js-shopping-cart-items"
.
<html lang="en-US"> | |
... lines 3 - 9 | |
<body> | |
<header class="header"> | |
<nav class="navbar navbar-expand-lg navbar-dark justify-content-between"> | |
... lines 13 - 22 | |
<ul class="navbar-nav"> | |
... lines 24 - 41 | |
<li class="nav-item"> | |
<a class="nav-link" href="#"> | |
Shopping Cart (<span id="js-shopping-cart-items">{{ count_cart_items() }}</span>) | |
</a> | |
</li> | |
</ul> | |
</nav> | |
</header> | |
... lines 50 - 69 | |
</body> | |
</html> |
Next, back in the component, after we successfully add the item to the cart, we can say document.getElementById()
- paste js-shopping-cart-items
- then .innerHTML = getCartTotalItems()
.
... lines 1 - 75 | |
<script> | |
import { fetchCart, addItemToCart, getCartTotalItems } from '@/services/cart-service.js'; | |
... lines 78 - 83 | |
export default { | |
name: 'ProductShow', | |
... lines 86 - 127 | |
methods: { | |
async addToCart() { | |
... lines 130 - 144 | |
document.getElementById('js-shopping-cart-items') | |
.innerHTML = getCartTotalItems(this.cart).toString(); | |
}, | |
... lines 148 - 151 | |
}, | |
}; | |
</script> | |
... lines 155 - 172 |
That's a new function that we haven't used yet. When I hit tab to auto-complete it, PhpStorm added the new import for us... though I'm going to move this all back onto one line.
Anyways, the function comes from cart-service
- the file we copied into our project a few minutes ago. Here it is. Very simply, it loops through the items and counts all of them using their quantity. A simple helper to get the number.
... lines 1 - 139 | |
/** | |
* Gets the total number of items in our shopping cart | |
* | |
* @param {CartCollection} cart | |
* @return {number} | |
*/ | |
export function getCartTotalItems(cart) { | |
return cart.items.reduce((acc, item) => (acc + item.quantity), 0); | |
} |
Back in product-show.vue
, down in the method, we can call getCartTotalItems()
and pass it this.cart
. Because this returns a Number, call .toString()
on the result.
... lines 1 - 75 | |
<script> | |
... lines 77 - 83 | |
export default { | |
name: 'ProductShow', | |
... lines 86 - 127 | |
methods: { | |
async addToCart() { | |
... lines 130 - 144 | |
document.getElementById('js-shopping-cart-items') | |
.innerHTML = getCartTotalItems(this.cart).toString(); | |
}, | |
... lines 148 - 151 | |
}, | |
}; | |
</script> | |
... lines 155 - 172 |
So... no. This is not the most hipster code that you will ever write. But it will work and give us the great user experience we want. If I needed to update this header from multiple places in my code, I would definitely isolate it into its own JavaScript module to avoid duplication.
Let's see if it works! Back at the browser, make sure to refresh so the span
gets the new id. Let's add 3 more green sofas. Watch the header... boom! That is so nice.
Next: with the add to cart done, let's create a new page: one that will display the shopping cart and checkout!
Hi there @Phil!
Wow, that's an annoying situation! So first, yes, while it seems like overkill, what you're saying is totally possible. And if you loaded the vue controller stuff lazily, users wouldn't even need to download the Vue JavaScript until they're on a page that renders a Vue component. Now, to your questions!
- We cannot get rid of jquery at the moment so we would essentially just use vue to display the datetimepicker and then send the value back to twig which would then send it to jquery for manipulation, how can we do that?
Exactly how you'd render it depends on how that date picker works. But I'd probably render the Vue component where it needs to live but possibly hidden by default. Then create a Stimulus controller, attach it to the "date input" and, on click, unhide the Vue component (you could even hide the wrapper around the Vue component, then unhide that wrapper). Or perhaps the Datepicker library is a bit more hands on, and handles opening itself. I'm doing some guessing there :).
Either way, there will be some way with the Datepicker component to know when a date is selected. From somewhere inside of Vue, I would probably dispatch a native browser event - https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent - on the input
element (or the root date picker element - it doesn't matter much - but make sure the event bubbles up). In a Stimulus controller that is attached to the element (or an ancestor) the event is dispatched on, listen to that event. Then, grab the value and do whatever you need to with jQuery.
Is it worth it? I read an article that says it gets more complex when using vue and twig and needs a lot of patches and roundabouts and in the case of our project it might even be more complicated with the jquery as well.
It's a pretty big thing to bring in JUST for a date picker :/, though I'm not too aware of patches and other problems. I'm sure you're already bummed out that you can't find a decent dater picker. So, yes, I would LOVE to AVOID this situation, but what's the alternative? If you CAN get the old datetimepicker to work... I would definitely try. But if not, yea, you don't want to create one of these things by hand... that'd be even more annoying.
Let me know if any of these ramblings help - I'd love to know what you end up doing.
Cheers!
Thank you @weaverryan, it definitely helps. The old datepicker used bootstrap 3 and is incompatible with bootstrap 5 so that is unfortunately not an option. The vue route seems like the only work-around but it feels patchy and over the top. But we might not have another option at the moment. I will keep you updated on what the final solution will be.
Cheers, Phil!
// 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
}
}
Hello @weaverryan,
I have a unique use case. We have huge project that started with symfony 2, bootstrap 3 and a whole lot of jquery to manipulate the dom. We upgraded the symfony version successfully and now we want to upgrade the front-end libraries to bootsrap 5 and latest jquery and basically latest versions of other JS packages. Two of those packages are datetimepicker and chosen (select on steroids) which are now deprecated. For the datetimepicker, we could not find a solution that works with latest technologies and also has a timezone option out of the box which is critical to the project. There are some datetimepickers that do that but they are either vue libraries or react libraries, that is where we started looking at symfony-ux-vue. Sorry for the long background tale but I needed to explain that to get to my questions:
Hopefully I made sense and looking forward to getting a response from you or anyone in the community.
Kind Regards.