Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Toast Notifications

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

We've made it to the last topic of the tutorial... so let's do something fun, like making it super easy to open "toast" notifications.

Toast notifications are those little messages that "pop up" like toast on the bottom - or top - of your screen. And Bootstrap has support for them. Our goal is simple but bold! I want to be able to trigger a toast notification from any template or from a Turbo Stream.

Creating the toast.html.twig Template

Start by creating a new template partial: _toast.html.twig. I'll paste in a structure that's from Bootstrap's documentation. Then let's make a few parts of this dynamic like {{ title }} - that's a variable we'll pass in... {{ when }} that defaults to just now and... for the body, {{ body }}.

<div class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<svg class="rounded me-2" width="20" height="20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" preserveAspectRatio="xMidYMid slice" focusable="false"><rect width="100%" height="100%" fill="#007aff"></rect></svg>
<strong class="me-auto">Bootstrap</strong>
<small>11 mins ago</small>
<button type="button" class="btn-close" data-bs-dismiss="toast"
aria-label="Close"></button>
</div>
<div class="toast-body">
Hello, world! This is a toast message.
</div>
</div>

Next, open up product/_reviews.html.twig. After submitting a new review, we render a flash message. Now I want this to be a toast notification! Cool! Include that template instead... and pass in a couple of variables like title set to Success and body set to the actual flash message content.

... lines 1 - 6
<turbo-frame id="product-reviews-form">
... line 8
{% for flash in app.flashes('review_success') %}
{{ include('_toast.html.twig', {
title: 'Success!',
body: flash
}) }}
{% endfor %}
... lines 15 - 36
</turbo-frame>

The Toast Stimulus Controller

If we stopped now... congratulations! Absolutely nothing would happen. These toast elements are invisible until you execute some JavaScript that opens them on the page. To do that, we need a Stimulus controller!

Up in the assets/controllers/ directory, create a new file called, how about, toast_controller.js. Inside, give this the normal structure where we import Controller from stimulus, export our controller... and have a connect() method that, of course, logs a loaf of bread.

import { Controller } from 'stimulus';
export default class extends Controller {
connect() {
console.log('?');
}
}

Over in _toast.html.twig, I want to activate this controller whenever this toast element appears on the page. No problemo: on the outer element, add {{ stimulus_controller('toast') }}.

<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" {{ stimulus_controller('toast') }}>
... lines 2 - 11
</div>

Our controller doesn't do anything yet, but let's at least make sure that it's connected. Head over to our site, refresh the page... make sure that your console is open... and then go fill out a new review. When we submit... got it! As soon as the toast HTML was rendered onto the page, our controller was initialized. Though... like I mentioned, you can't actually see the toast element yet. It's taking up some space... but it's invisible.

Let's fix that! Back in the controller, import Toast from bootstrap. Below add const toast = new Toast() and pass it this.element. To open the toast, say toast.show().

import { Controller } from 'stimulus';
import { Toast } from 'bootstrap';
export default class extends Controller {
connect() {
const toast = new Toast(this.element);
toast.show();
}
}

That's it! Refresh again and add another review. This time... that's super cool! And it means that we can, from anywhere, render the _toast.html.twig template and it will activate this behavior.

Grouping all the Toasts into One Container

Though... the positioning isn't what I was imagining. Before it disappeared, it was open... right in the middle of the page. I was hoping to put it in the top right corner of the screen.

To do that, we just need to add a few classes to the toast element. Except... there's one other minor problem. If you think about it, it's possible that a user could see multiple toast notifications at the same time. The toast system totally supports this.... it stacks them on top of each other. But for that to work, we need to have a single global "toast container" element on our page that all individual toasts live inside of.

This might be easier to show. Open up templates/base.html.twig. Really, anywhere, but I'll go to the bottom, add a <div> with id="toast-container. That could be anything: we'll use this id to find this element in JavaScript.

Also add class="toast-container" and a few other classes. toast-container helps Bootstrap stack any toasts inside of this... and everything else puts the toast in the upper right corner of the screen.

<!DOCTYPE html>
<html lang="en-US">
... lines 3 - 14
<body>
... lines 16 - 101
<div
id="toast-container"
class="toast-container position-fixed top-0 end-0 p-3"
></div>
</body>
</html>

Now, in order for this to work, we need all the toast notifications to physically live inside of this toast-container element. So basically, we need to render _toast.html.twig... and somehow get that HTML inside of the container.

But... I don't want to do that! I want to keep the flexibility of being able to render _toast.html.twig from... wherever and have it work. And we can still have this with a little help from our Stimulus controller.

Check it out: at the top of connect(), add const toastContainer = document.getElementById() and pass it toast-container to find the element that lives at the bottom of the page. Then... let's move ourselves into that: toastContainer.appendChild(this.element).

And now that it lives inside the container, we open it like normal!

Though... there is one subtle "catch". When the toast HTML initially loads, it will live here in the middle of the page. Naturally, Stimulus notices this element, instantiates a new controller instance and calls connect(). Yay! But when we move this.element into toast-container, Stimulus destroys the original controller instance, creates a new one, and calls connect() a second time.

In other words, the connect() method will be called twice: once when we originally render our toast element onto the page and again after we move into toast-container. Right now, that's going to cause an infinite loop where we call appendChild() over and over again.

To avoid that, add, if this.element.parentNode does not equal toastContainer. So only if the element has not been moved yet, move it... and then return. The first time this executes, it will move the element and exit. The second time it executes, it will skip all of this and pop open the toast.

import { Controller } from 'stimulus';
import { Toast } from 'bootstrap';
export default class extends Controller {
connect() {
const toastContainer = document.getElementById('toast-container');
if (this.element.parentNode !== toastContainer) {
toastContainer.appendChild(this.element);
return;
}
const toast = new Toast(this.element);
toast.show();
}
}

Let's try this thing! Refresh the page, add another review and... beautiful! If you quickly inspect the toast element... yup! It lives down inside of toast-container.

Publishing a Toast through Mercure to All Users

Ok, I have one last micro-challenge: whenever a new review is added to a product, I want to open a toast notification on every user's screen that's currently viewing the product. Something that says:

Hey! This product has a new review!

Over in Review.stream.html.twig, in the create block, add another turbo stream with action="append" and target=""... well... leave that empty for a minute. Give this the template element, include _toast.html.twig and pass in a few variables: title set to New Review and body set to

A new review was just posted for this product.

{% block create %}
... lines 2 - 18
<turbo-stream action="append" target="product-{{ entity.product.id }}-toasts">
<template>
{{ include('_toast.html.twig', {
title: 'New Review!',
body: 'A new review was just posted for this product'
}) }}
</template>
</turbo-stream>
{% endblock %}
... lines 28 - 49

Very nice! But... what should the target be? We could use toast-container. That would append it to this element. But... then the message would show up on every page. We only want this message to show up if you're viewing this specific product.

To do that, we need to target an element that only exists on this specific product's page. Open show.html.twig. Right inside of the product_body block, let's add an empty div with id="product-{{ product.id }}-toasts"

{% extends 'product/productBase.html.twig' %}
... line 2
{% block productBody %}
<div id="product-{{ product.id }}-toasts"></div>
... lines 5 - 49
{% endblock %}

A little empty element just for our toasts to go into. Copy this and, in Review.stream.html.twig, target it. Except that we need entity.product.id.

Let's check it out! Refresh the page... and then open the same product in another tab to "mimic" what a different user would see. Scroll down, fill in a review and... submit. Awesome! We have two toasts over here and... the other user sees the one toast! The two toast notifications in our first tab is a bit weird, but I'll leave it for now.

And... we're done! Woh! Congrats to you! You deserve a nice crisp high five... and maybe a short vacation for making it through this huge tutorial. It was huge because... well... Turbo has a lot to offer. I hope you're as excited about the possibilities of Stimulus and Turbo as I am.

Let us know what you're building. And, as always, if you have any questions, we're here for you down in the comments section.

All right, friends. See you next time!

Leave a comment!

15
Login or Register to join the conversation
Braunstetter Avatar
Braunstetter Avatar Braunstetter | posted 1 year ago

Hey Ryan! Thank you for this awesome tutorial.

3 Reply
Nick-F Avatar

The way I see it, there are 2 approaches to target specific pages with mercure turbo streams.

  1. You can add an identifier to the target element's ID, like you do in this course
  2. You can add an identifier to the topic that is being subscribed to

In (1)'s case, the number of unique topics being subscribed to is low, but there are a lot more updates being sent to the browser (most of them just won't have an effect on the page)
In (2)'s case, there are many more unique topics being subscribed to, but only the necessary updates are being sent to each page.

Let's say we have 100 simultaneous users, all subscribing and getting updates,
Is one of the options better in terms of performance?
Would having too many different topics create a bottleneck in the mercure hub? Is this limited by anything (i.e. available cpu threads)?
Or would having too many updates being sent create a bottleneck in the symfony app?

Reply

Hey Nick!

First, I'm not an expert on this. So, I'll tell you what I know, but it's not coming from a place of real-world experience with many subscribers/updates like this.

From the Mercure side of things, the managed pricing page can kind of give you some clues: https://mercure.rocks/pricing. In general, I think the answer is that you'll run into bottlenecks performing too many updates first before you'll run into bottlenecks on Mercure.

For example, on the Hobby plan, you get 250 concurrent connections and 30 POST request / minute. So, with your example, if we have 100 users and each is subscribed to the same ONE topic, then that's 100 concurrent users, still well below the limit. And, you only need to publish ONE update.

If each user is subscribed to a different topic, then that's still 100 concurrent users. In theory, you (I believe) update all 100 topics with ONE update (because you can pass multiple topics to an Update) if all of those updates are identical. I have no idea if there is some upper limit on the number of topics you can pass. Or, if each Update needs to be a bit different (for each topic), then you'd need 100 updates, and that WOULD be a problem (since the limit at this level is 30 / minute).

So, in general, your bottleneck is probably more about how many updates you post vs concurrent users. Or, to say it differently, I would tend to design things so that I need only one / a few updates. Then, I would scale my Mercure instance based on how many concurrent users I need.

Let me know if this helps - it CAN be tricky to get right. Another thing to consider (based on your situation) is that you COULD send 100 updates to Mercure without slowing down your app by using Messenger to send these. The downside would just be that those updates might not be as instant, as it might take some time for your worker to process through them.

Cheers!

Reply
SamuelVicent Avatar
SamuelVicent Avatar SamuelVicent | posted 11 months ago

Hi Ryan,

We have some questions for you :) Having something out of the box would be great o giving us some easy way to accomplish would be awsome as well :)

  • Is there any way to load a static content? Let me explain. Imagine a web page with 2 links, one of them needs to load content from server, and another just show a pre-loaded content that was previously hidden. We though about creating a controller which first link loads content via Turbo.visit('url') (needed to import the turbo library) and the other just shows a hidden static content (we find this approach a bit cumbersome).

  • Is there any way to load dynamic content only once?

Thanks you very much in advance :) !

Reply

Hey @SamuelVicent!

Nice to chat with you - I'll do my best to brainstorm :).

Is there any way to load a static content?

I'm not sure I fully understand this still. For the link that loads content from the server, that is just a normal link, right? You shouldn't need to do anything (write any custom JavaScript) - you can just allow the link to be a normal link and then, on click, Turbo will follow it. I feel I'm missing some important detail here (because that answer is too simple) - so please let me know :).

and another just show a pre-loaded content that was previously hidden

Turbo won't help you here - this is more of a simple "hide/show this element" situation. In my app, if I were doing this with any frequency, I'd create a general-purpose Stimulus controller for this - the end Twig would look something like:

<div {{ stimulus_controller('toggle-content')>
    <button {{ stimulus_action('toggle-content', 'show') }}>Show</button>
    <div style="display: none;" {{ stimulus_target('toggle-content', 'content') }}>
        I am hidden content
    </div>
</div>

When the button is clicked, the show() method in the Stimulus controller looks for the content target and unhides it.

Again, I feel like I may not be fully grasping your situation (and so I might be answering the wrong question!) so let me know :).

Is there any way to load dynamic content only once?

What do you mean? Are you referring what happens when you click a normal link and Turbo loads the next "page"? Are you working with Turbo frames? Something else?

Let me know some of these extra details and I'll see if I can help further :).

Cheers!

Reply
SamuelVicent Avatar

Hi Ryan,

  • First point understood! thanks a lot. We understand that is not turbo responsability hiding and showing divs :)

  • Second point, sorry for my description, let me explain a bit better. Using turbo, imagine a page with two links, a user first clicks on LinkA, then on LinkB, and then on LinkA again. The question is: Is there any way to keep the Snapshop previously created when user clicked LinkA for the second time? The objective of this approach is not reloading the same content twice. This behaviour could happen inside a turbo-frame as well.

Thanks a lot!

Reply

Hey @SamuelVicent!

Ok, I think I understand then! Here is the flow:

1) User clicks Link A. The URL for LinkA is downloaded then shown
2) User clicks Link B. The URL for LinkB is downloaded then shown.
3) User clicks Link A again. The original, cached version of Link A is shown immediately. But the URL for LinkA IS downloaded again, and then shown to the user when it finishes.

If I understand correctly, you'd like to remove this part:

But the URL for LinkA IS downloaded again, and then shown to the user when it finishes.

And thus just show the original, cached version of Link A and do nothing else. Is that correct?

If so... I'm not sure! :). I am not aware of a built-in way to do this (i.e. I am not aware of any data-turbo="restore" type of attribute you could add somewhere to opt into this). To do this, I would try to "cancel" the visit - https://turbo.hotwired.dev/handbook/drive#canceling-visits-before-they-start - but I don't know if this would work. It would only work if canceling would cancel the request but Turbo still shows the page from cache. I just don't know exactly how Turbo will behave. Also, you would need to somehow figure out whether or not the page is already in the cache or not. That info may be present on event.detail (?) or by checking of the current html element has a data-turbo-preview attribute on it (assuming that the preview is shown BEFORE your event listener is called).

So, that's one idea... which may or may not work - I'm not sure :). But I'd be curious to hear if it did!

Cheers!

Reply
SamuelVicent Avatar
SamuelVicent Avatar SamuelVicent | weaverryan | posted 11 months ago | edited

Hi Ryan,

We made some tests and finally could cancel a second hit to server.
Here you are a minimal code in javascript:

const urls = new Set();
document.addEventListener('turbo:before-fetch-request', (event) => {
    const url = event.detail.url.href;
    if (urls.has(url)) {
        event.preventDefault();
    } else {
        urls.add(url);
    }
});

Would have been great having a buit-in for this behaviour (something like you mention like data-turbo="restore"), but this suits our needs :)

Thanks a lot for your help!!!

Reply

Hey @SamuelVicent!

Ah, I love this solution and approach - very clever :). Thanks for sharing it.

Cheers!

1 Reply
Olivier Avatar
Olivier Avatar Olivier | posted 1 year ago

Very nice tutorial, very informative.

There is one point that I was looking forward that sadly was not covered by the tutorial: private streams and/or different broadcasts.

Three different use cases:

1) I have a "buyer" and a "seller" Entity, when one buys from the other, they both get a toast message, but with a different message. One would say "You just bought for 15EUR" and the other one "You just sold for 10EUR".
So I need a way to broadcast changes depending on the logged in user. Is there any ways to do that ?
I thought about playing with the ids and just streaming both versions of the message, letting the one that is actually on the page be updated, but I'm concerned that one could look at his Mercure stream and see information that he shouldn't (like the "real" price the seller gets).

2) A Facebook-like notification system, where you get notified only on things that concerns you.

3) In the tutorial we did "When a review is updated on the admin site, update the frontend", using Broadcasts. What about doing the opposite ? If the review is changed on the frontend OR by another admin, i'd like my view updated too.

Thanks !

Reply

Hey @Growiel!

> There is one point that I was looking forward that sadly was not covered by the tutorial: private streams and/or different broadcasts

Yea... I didn't want to get into the weeds with this, since this tutorial isn't strictly about Mercure. But it's definitely a missing piece.

> 1) I have a "buyer" and a "seller" Entity, when one buys from the other, they both get a toast message, but with a different message. One would > say "You just bought for 15EUR" and the other one "You just sold for 10EUR".
> So I need a way to broadcast changes depending on the logged in user. Is there any ways to do that ?

Say the "buyer" has a user id of "10" in the database and the seller has a user id of "20". You could have the buyer listen to user_10 and the seller listen to user_20. Then, when the transaction is complete, you would post 2 updates: one to user_10 (with the "You just bought" message) and one to user_20 (with the "You just sold" message). There are other slightly variations on how to accomplish this, but I think this is the general idea :).

For both this situation (and the next one described below), you would need to make sure that only the authorized user can listen to their channel (e.g. we need to prevent the seller from being able to listen to the buyer's channel).

> 2) A Facebook-like notification system, where you get notified only on things that concerns you.

I would have each user listen to a user_ID channel, very similar to what I described above. The only downside is that this can cause you to have a lot of "listeners" to Mercure, which could cause you to need more Mercure resources (each user would always be listening to at least 1 channel).

> 3) In the tutorial we did "When a review is updated on the admin site, update the frontend", using Broadcasts. What about doing the opposite ? If the review is changed on the frontend OR by another admin, i'd like my view updated too.

That's a cool idea! The Broadcast feature would work nicely with this: we could some additional stream elements to the template that target elements that are only found in the admin section. Then, we just need to make sure that, in the admin section, we are also listening to that entity's channel.

That's the simplest solution: make the broadcast template update ALL possible elements, both on the frontend and backend. The downside is that a clever user could look into the stream and see the HTML from your admin area. Depending on the situation, that could definitely be a problem :). If it is, then you will, one way or another, need to dispatch two updates whenever you change a review: one for the frontend and one for the admin area (each update would be publishing to a different channel).

Let me know if that helps :). It's a bit of a challenge sometimes to figure out the best arrangement of "channels" to optimize "number of listeners" and "number of updates" and "privacy".

Cheers!

Reply
Olivier Avatar

Thanks a lot for answering, somehow I did not a notification from Disqus.

As you can see all of my use cases rely upon one thing: privacy. We don't want someone seeing something he shouldn't by using his devtools to look at he stream(s).

I already thought of the "one user = one stream" technique, and it's the one I'm using at the moment, just wanted to pick your brain on if there's something more elegant because it does have some limitations.

For the last use case, we'd have to go with custom updates right ? Broadcasts would only allow us to broadcast either the front template or the admin one (unless we're okay with the security issues).

Reply

Hey @Growiel!

> Thanks a lot for answering, somehow I did not a notification from Disqus

That's ok! I'm glad you saw the reply :)

> just wanted to pick your brain on if there's something more elegant because it does have some limitations.

If there is a more elegant solution, I would also like to know it. So, there may be one, but not that I'm aware of.

> For the last use case, we'd have to go with custom updates right ? Broadcasts would only allow us to broadcast either the front template or the admin one (unless we're okay with the security issues).

Exactly. If you used the Broadcast, then the broadcast template would need to include both the frontend and backend HTML... which maybe you're ok with, or maybe not :). So probably you would need to make the Broadcast update just the frontend templates and dispatch a second, custom Update for an admin-only Mercure channel.

Cheers!

Reply
Nick-F Avatar

That was absolutely massive. I appreciate all the work that must have went into this.
I love the quizzes too.
And the longer course length created opportunities for similar issues to pop up multiple times throughout the course, so after the first time I did something, the next time it came up I could try to figure it out myself before following along with the video. I think this helps to alleviate the one-sided aspect of video courses: when it comes to learning, your brain only keeps what it uses, you have to inhale new info and exhale problem solving with that new info, and this course allowed plenty of room to breath.
I think it took around 20 hours to finish lol but I feel like I'm now part of an exclusive elite group of symfony developers who've made it this far.

Reply

Hey Nick!

Thank you for your feedback! We're happy to hear you love this, and that's why we do want our users to code along with the course author - it definitely worth it, the material is learned much better than when you just watch videos without coding.

Cheers!

Reply
Cat in space

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

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "1.11.99.1", // 1.11.99.1
        "doctrine/annotations": "^1.0", // 1.13.1
        "doctrine/doctrine-bundle": "^2.2", // 2.3.2
        "doctrine/orm": "^2.8", // 2.9.1
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^6.1", // v6.1.4
        "symfony/asset": "5.3.*", // v5.3.0-RC1
        "symfony/console": "5.3.*", // v5.3.0-RC1
        "symfony/dotenv": "5.3.*", // v5.3.0-RC1
        "symfony/flex": "^1.3.1", // v1.18.5
        "symfony/form": "5.3.*", // v5.3.0-RC1
        "symfony/framework-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/property-access": "5.3.*", // v5.3.0-RC1
        "symfony/property-info": "5.3.*", // v5.3.0-RC1
        "symfony/proxy-manager-bridge": "5.3.*", // v5.3.0-RC1
        "symfony/runtime": "5.3.*", // v5.3.0-RC1
        "symfony/security-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/serializer": "5.3.*", // v5.3.0-RC1
        "symfony/twig-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/ux-chartjs": "^1.1", // v1.3.0
        "symfony/ux-turbo": "^1.3", // v1.3.0
        "symfony/ux-turbo-mercure": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.0-RC1
        "symfony/webpack-encore-bundle": "^1.9", // v1.11.2
        "symfony/yaml": "5.3.*", // v5.3.0-RC1
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.1
        "twig/intl-extra": "^3.2", // v3.3.0
        "twig/string-extra": "^3.3", // v3.3.1
        "twig/twig": "^2.12|^3.0" // v3.3.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.0
        "symfony/debug-bundle": "^5.2", // v5.3.0-RC1
        "symfony/maker-bundle": "^1.27", // v1.31.1
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/stopwatch": "^5.2", // v5.3.0-RC1
        "symfony/var-dumper": "^5.2", // v5.3.0-RC1
        "symfony/web-profiler-bundle": "^5.2", // v5.3.0-RC1
        "zenstruck/foundry": "^1.10" // v1.10.0
    }
}

What JavaScript libraries does this tutorial use?

// package.json
{
    "devDependencies": {
        "@babel/preset-react": "^7.0.0", // 7.13.13
        "@fortawesome/fontawesome-free": "^5.15.3", // 5.15.3
        "@hotwired/turbo": "^7.0.0-beta.5", // 1.2.6
        "@popperjs/core": "^2.9.1", // 2.9.2
        "@symfony/stimulus-bridge": "^2.0.0", // 2.1.0
        "@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/Resources/assets", // 1.1.0
        "@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/Resources/assets", // 0.1.0
        "@symfony/ux-turbo-mercure": "file:vendor/symfony/ux-turbo-mercure/Resources/assets", // 0.1.0
        "@symfony/webpack-encore": "^1.0.0", // 1.3.0
        "bootstrap": "^5.0.0-beta2", // 5.0.1
        "chart.js": "^2.9.4",
        "core-js": "^3.0.0", // 3.13.0
        "jquery": "^3.6.0", // 3.6.0
        "react": "^17.0.1", // 17.0.2
        "react-dom": "^17.0.1", // 17.0.2
        "regenerator-runtime": "^0.13.2", // 0.13.7
        "stimulus": "^2.0.0", // 2.0.0
        "stimulus-autocomplete": "https://github.com/weaverryan/stimulus-autocomplete#toggle-event-always-dist", // 2.0.0
        "stimulus-use": "^0.24.0-1", // 0.24.0-2
        "sweetalert2": "^11.0.8", // 11.0.12
        "webpack-bundle-analyzer": "^4.4.0", // 4.4.2
        "webpack-notifier": "^1.6.0" // 1.13.0
    }
}
userVoice