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 SubscribeIf we add a new product with valid data - we're going to start selling solar powered flashlights... I got a great deal on them - it does work... but it's not very obvious. You don't see the new product until you reload the page. We could, on success, add a message to the top of the page. That's actually a really great idea. But even if we did, we should also reload the product list so the user can see the new item.
If you think about it, being able to make an Ajax call to a URL... and then use the HTML from that to replace the contents of an element is... kind of a common thing to do. Heck, we basically already do this on the cart page! After removing an item, we make an Ajax call to get the fresh cart list.
So here's the plan: instead of adding more logic to our modal-form
controller, which would make it less reusable, let's create a second controller that will make an Ajax call to reload the product list area after a new product is successfully added. To be extra cool - cause we are extra cool - we're going to make this new controller generic so we can reuse it anywhere... like on the cart page!
Head into templates/product_admin/index.html.twig
: the template for the product list page. Let's see. The area that we need to refresh after submit is really just this table. But I'm going to add the new data-controller
to the top level div. Let's break it on multiple lines... fix my super old typo on container
- no wonder the page didn't look very good - then add {{ stimulus_controller() }}
. Call the new controller, how about, reload-content
.
... lines 1 - 4 | |
{% block body %} | |
<div | |
... line 7 | |
{{ stimulus_controller('reload-content') }} | |
> | |
... lines 10 - 68 | |
</div> | |
{% endblock %} |
Why are we adding it here... and not directly around the table? Well, in order for the reload-controller
to know when a form was submitted successfully, we're going to use an old trick: we will dispatch an event from modal-form
controller. To listen to that event, reload-controller
needs to live on an element that is around both modal-form
controller and the <table>
that it needs to update.
Let's go add the new file. In assets/controllers/
create reload-content_controller.js
. Steal the entire cart-list_controller
- since it's so similar - close it and paste. Add a connect()
method with console.log()
a refresh icon.
import { Controller } from 'stimulus'; | |
export default class extends Controller { | |
static values = { | |
cartRefreshUrl: String, | |
} | |
connect() { | |
console.log('?'); | |
} | |
async removeItem(event) { | |
event.currentTarget.classList.add('removing'); | |
const response = await fetch(this.cartRefreshUrlValue); | |
this.element.innerHTML = await response.text(); | |
} | |
} |
Let's give it a go! Refresh the page, check out the logs and... we are connected!
To be able to refresh the content of this table, we need an endpoint that returns just the table. To do that, we need to isolate the table into a template partial... like we've done before. Copy the entire table, delete it and then, in this same directory - product_admin
- create a new file called _list.html.twig
. Paste the table here.
<table class="table"> | |
<thead> | |
<tr> | |
<th>Id</th> | |
<th>Name</th> | |
<th>Description</th> | |
<th>Brand</th> | |
<th>Weight</th> | |
<th>Price</th> | |
<th>StockQuantity</th> | |
<th>ImageFilename</th> | |
<th>actions</th> | |
</tr> | |
</thead> | |
... lines 15 - 36 | |
</table> |
Back in index.html.twig
include that with {{ include('product_admin/_list.html.twig') }}
.
... lines 1 - 4 | |
{% block body %} | |
<div | |
... lines 7 - 10 | |
> | |
... lines 12 - 31 | |
{{ include('product_admin/_list.html.twig') }} | |
... lines 33 - 34 | |
</div> | |
{% endblock %} |
If we refresh now... so far so good: nothing changes.
Like we did with the new product form, the simplest way to create an endpoint that will return just the table is to make the index
action capable of returning a full page of HTML - like it's doing right now - or just the _list
template partial.
Let's try the same trick as before. First, add a Request
argument - the one from HttpFoundation - and then say $template = $request->isXmlHttpRequest()
. If this is an Ajax request, use _list.html.twig
. Else, use the template we're using now, index.html.twig
. Below, we can replace the index.html.twig
with $template
.
... lines 1 - 15 | |
class ProductAdminController extends AbstractController | |
{ | |
... lines 18 - 20 | |
public function index(ProductRepository $productRepository, Request $request): Response | |
{ | |
$template = $request->isXmlHttpRequest() ? '_list.html.twig' : 'index.html.twig'; | |
return $this->render('product_admin/' . $template, [ | |
... line 26 | |
]); | |
} | |
... lines 29 - 104 | |
} |
Awesome! Now, copy the route name so we can pass this into our controller. We'll use a value. Start by defining that in our controller. Or, really, just rename cartRefreshUrl
to just url
and it will be a String
. I'll also remove the connect()
method.
... lines 1 - 2 | |
export default class extends Controller { | |
... line 4 | |
static values = { | |
url: String, | |
} | |
... lines 8 - 12 | |
} |
Pass the value in via the template. So up at the top, on a second argument to stimulus_controller()
, set url
to path('product_admin_index')
.
... lines 1 - 4 | |
{% block body %} | |
<div | |
... line 7 | |
{{ stimulus_controller('reload-content', { | |
url: path('product_admin_index') | |
}) }} | |
> | |
... lines 12 - 36 | |
</div> | |
{% endblock %} |
Lovely! Before we use that to make the Ajax call, when that call finishes, we're going to need to know where we should put the new HTML. Let's wrap the table in a new div
and make it a target: data-reload-content-target=""
and... call it content
.
... lines 1 - 4 | |
{% block body %} | |
<div | |
... lines 7 - 10 | |
> | |
... lines 12 - 31 | |
<div data-reload-content-target="content"> | |
{{ include('product_admin/_list.html.twig') }} | |
</div> | |
... lines 35 - 36 | |
</div> | |
{% endblock %} |
Go set up that target. At the top of the class, add static targets = []
with content
inside.
... lines 1 - 2 | |
export default class extends Controller { | |
static targets = ['content']; | |
... lines 5 - 12 | |
} |
Now let's make the Ajax call. Rename the method to refreshContent()
. We're not using this method anywhere... but we will soon. Let's see: we don't need to add this class... the value changed to this.urlValue
... and instead of using this.element
, use this.contentTarget
... lines 1 - 2 | |
export default class extends Controller { | |
... lines 4 - 8 | |
async refreshContent(event) { | |
const response = await fetch(this.urlValue); | |
this.contentTarget.innerHTML = await response.text(); | |
} | |
} |
As I mentioned, nobody is calling refreshContent()
yet. But if they did, it should make the Ajax call and replace the table with the new HTML. So... how will we call this method?
Well, we need to call it after the modal-form
controller finishes submitting successfully. So next: let's dispatch a custom event from modal-form_controller
and listen to it so that we can reload the content after it's successful.
Once we're done, we'll prove that our new controller is reusable by completely replacing the cart-list_controller
with the new one. Yay for less custom code!
"Houston: no signs of life"
Start the conversation!
// 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.11.1
"doctrine/doctrine-bundle": "^2.2", // 2.2.3
"doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.2
"doctrine/orm": "^2.8", // 2.8.1
"phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
"sensio/framework-extra-bundle": "^5.6", // v5.6.1
"symfony/asset": "5.2.*", // v5.2.3
"symfony/console": "5.2.*", // v5.2.3
"symfony/dotenv": "5.2.*", // v5.2.3
"symfony/flex": "^1.3.1", // v1.18.5
"symfony/form": "5.2.*", // v5.2.3
"symfony/framework-bundle": "5.2.*", // v5.2.3
"symfony/property-access": "5.2.*", // v5.2.3
"symfony/property-info": "5.2.*", // v5.2.3
"symfony/proxy-manager-bridge": "5.2.*", // v5.2.3
"symfony/security-bundle": "5.2.*", // v5.2.3
"symfony/serializer": "5.2.*", // v5.2.3
"symfony/twig-bundle": "5.2.*", // v5.2.3
"symfony/ux-chartjs": "^1.1", // v1.2.0
"symfony/validator": "5.2.*", // v5.2.3
"symfony/webpack-encore-bundle": "^1.9", // v1.11.1
"symfony/yaml": "5.2.*", // v5.2.3
"twig/extra-bundle": "^2.12|^3.0", // v3.2.1
"twig/intl-extra": "^3.2", // v3.2.1
"twig/twig": "^2.12|^3.0" // v3.2.1
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.0
"symfony/debug-bundle": "^5.2", // v5.2.3
"symfony/maker-bundle": "^1.27", // v1.30.0
"symfony/monolog-bundle": "^3.0", // v3.6.0
"symfony/stopwatch": "^5.2", // v5.2.3
"symfony/var-dumper": "^5.2", // v5.2.3
"symfony/web-profiler-bundle": "^5.2" // v5.2.3
}
}
// package.json
{
"devDependencies": {
"@babel/preset-react": "^7.0.0", // 7.12.13
"@popperjs/core": "^2.9.1", // 2.9.1
"@symfony/stimulus-bridge": "^2.0.0", // 2.1.0
"@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/Resources/assets", // 1.1.0
"@symfony/webpack-encore": "^1.0.0", // 1.0.4
"bootstrap": "^5.0.0-beta2", // 5.0.0-beta2
"core-js": "^3.0.0", // 3.8.3
"jquery": "^3.6.0", // 3.6.0
"react": "^17.0.1", // 17.0.1
"react-dom": "^17.0.1", // 17.0.1
"regenerator-runtime": "^0.13.2", // 0.13.7
"stimulus": "^2.0.0", // 2.0.0
"stimulus-autocomplete": "^2.0.1-phylor-6095f2a9", // 2.0.1-phylor-6095f2a9
"stimulus-use": "^0.24.0-1", // 0.24.0-1
"sweetalert2": "^10.13.0", // 10.14.0
"webpack-bundle-analyzer": "^4.4.0", // 4.4.0
"webpack-notifier": "^1.6.0" // 1.13.0
}
}