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 SubscribeHere's the plan. Since we're using Dropzone to upload things via Ajax, I want to transform this entire section into a fully JavaScript-driven dynamic widget. Some of this stuff we're going to talk about isn't strictly related to handling uploads, but I got a lot of requests to show a full upload "gallery" where you can upload, edit, delete and re-order files. So... let's do that!
Select another file to upload, like rocket.jpeg
. It uploads... but you don't see it on the list until we refresh. Lame! Instead of rendering this list inside Twig, let's render it via JavaScript. Once we've done that, updating it dynamically will be easy!
To power the frontend, we need a new API endpoint that will return all of the references for a specific Article. We got this: go into ArticleReferenceAdminController
and create a new public function called getArticleReferences()
.
... lines 1 - 18 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
... lines 21 - 79 | |
public function getArticleReferences(Article $article) | |
{ | |
... line 82 | |
} | |
... lines 84 - 107 | |
} |
Add the @Route()
above this with /admin/article/{id}/references
.
This time, the id
is the article id. URLs aren't technically important, but this is on purpose: in an API, /admin/article/{id}
would be the URL to get info about a specific article. Adding /references
onto that is a nice way to read its references.
Now add the methods="GET"
- yes you can leave off the curly braces when there's just one method - and name="admin_article_list_references"
.
... lines 1 - 18 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
... lines 21 - 75 | |
/** | |
* @Route("/admin/article/{id}/references", methods="GET", name="admin_article_list_references") | |
... line 78 | |
*/ | |
public function getArticleReferences(Article $article) | |
{ | |
... line 82 | |
} | |
... lines 84 - 107 | |
} |
Down in the method, add the Article
argument and don't forget the security check: @IsGranted("MANAGE", subject="article")
. We can use the annotation this time because we do have an article
argument. Then, oh, it's beautiful: return $this->json($article->getArticleReferences());
.
... lines 1 - 18 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
... lines 21 - 75 | |
/** | |
* @Route("/admin/article/{id}/references", methods="GET", name="admin_article_list_references") | |
* @IsGranted("MANAGE", subject="article") | |
*/ | |
public function getArticleReferences(Article $article) | |
{ | |
return $this->json($article->getArticleReferences()); | |
} | |
... lines 84 - 107 | |
} |
How nice is it!? Let's check it out: in the browser, take off the /edit
and replace it with /references
. And... oh boy, it explodes!
Semantical error: Couldn't find constant article... make sure annotations are installed and enabled.
Well, they are - this is a total rookie mistake I made with my annotations. On the @IsGranted
annotation, it should be subject="article"
. Try it again. Here we go - that's the error I was expecting: our favorite circular reference has been detected.
This is the exact same thing we saw a second ago when we tried to serialize a single ArticleReference
. And the fix is the same: we need to use the main
serialization group.
Pass 200 as the status code, no custom headers, but one custom groups
option set to main
.
... lines 1 - 18 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
... lines 21 - 79 | |
public function getArticleReferences(Article $article) | |
{ | |
return $this->json( | |
$article->getArticleReferences(), | |
200, | |
[], | |
[ | |
'groups' => ['main'] | |
] | |
); | |
} | |
... lines 91 - 114 | |
} |
Try it again. Gorgeous! That contains everything we need to render the list in JavaScript.
To do that, we're not going to use Vue.js or React. Those are both wonderful options, and if you're serious about building some high-quality front-end apps, you need to give them a serious look. But, to keep the concepts understandable, I'm going to stick to jQuery and a few modern JavaScript techniques.
Start in edit.html.twig
. Find the list and completely empty it: we'll fill this in via JavaScript. But add a new class so we can find it: js-reference-list
. Let's also add a data-url
attribute: I want to print the URL to our new endpoint to make it easy for JavaScript to fetch the references. Copy the new route name, paste it into path
and add pass the id
route wildcard set to article.id
.
... lines 1 - 2 | |
{% block content_body %} | |
... lines 4 - 7 | |
<div class="row"> | |
... lines 9 - 14 | |
<div class="col-sm-4"> | |
... lines 16 - 17 | |
<ul class="list-group small js-reference-list" data-url="{{ path('admin_article_list_references', {id: article.id}) }}"></ul> | |
... lines 19 - 25 | |
</div> | |
</div> | |
{% endblock %} | |
... lines 29 - 42 |
Next, in admin_article_form.js
, I'm going to paste in a class that I've started: you can copy this from the code block on this page. This uses the newer "class" syntax from JavaScript... which is compatible with most browsers, but not all of them. That's why I've added this note to use Webpack Encore, which will rewrite the new syntax so that it's compatible with whatever browsers you need.
... lines 1 - 33 | |
// todo - use Webpack Encore so ES6 syntax is transpiled to ES5 | |
class ReferenceList | |
{ | |
constructor($element) { | |
this.$element = $element; | |
this.references = []; | |
this.render(); | |
$.ajax({ | |
url: this.$element.data('url') | |
}).then(data => { | |
this.references = data; | |
this.render(); | |
}) | |
} | |
render() { | |
const itemsHtml = this.references.map(reference => { | |
return ` | |
<li class="list-group-item d-flex justify-content-between align-items-center"> | |
${reference.originalFilename} | |
<span> | |
<a href="/admin/article/references/${reference.id}/download"><span class="fa fa-download"></span></a> | |
</span> | |
</li> | |
` | |
}); | |
this.$element.html(itemsHtml.join('')); | |
} | |
} | |
... lines 66 - 84 |
Before we dive into this class, let's start using it up on our document.ready()
function. Say var referenceList = new ReferenceList()
and pass it $('.js-reference-list')
- that's the element we just added the attribute to.
... lines 1 - 2 | |
$(document).ready(function() { | |
var referenceList = new ReferenceList($('.js-reference-list')); | |
... lines 5 - 31 | |
}); | |
... lines 33 - 84 |
And... yea! The class mostly takes care of the rest! In the constructor()
, we take in the jQuery element and store it on this.$element
. It also keeps track of all the references that it has, which starts empty and calls this.render()
, whose job is to completely fill the ul
element.
this.references.map
is a fancy way to loop over the references array, which is empty at the start, but won't be forever. For each reference, it creates a string of HTML that is basically a copy of what we had in our template before. This uses a feature called template literals that allows us to create a multi-line string with variables inside - like reference.originalFilename
and referenced.id
. The data from the references will ultimately come from our new endpoint, so I'm using the same keys that our JSON has.
I did hardcode the URL to the download endpoint instead of doing something fancier. You could generate that with FOSJsRoutingBundle if you want, but hardcoding it is also not a huge deal.
Finally, at the bottom, we take all that HTML and stick it into the element. This is a bit similar to what React does, but definitely less powerful.
Back up in the constructor, the references array starts empty, but we immediately make an Ajax call by reading the data-url
attribute off of our element. When it finishes, we set this.references
to its data and once again call this.render()
.
Phew! Let's see if it actually works! Refresh and... yes! If you watched closely, it was empty for a moment, then filled in once the AJAX call finished.
Now that we're rendering this in JavaScript, we have a clean way to add a new row whenever a file finishes uploading. Back inside the init
function for Dropzone, add another event listener: this.on('success')
and pass a callback with the same file
and data
arguments. To start, just console.log(data)
so we can see what it looks like.
... lines 1 - 66 | |
function initializeDropzone() { | |
... lines 68 - 72 | |
var dropzone = new Dropzone(formElement, { | |
... line 74 | |
init: function() { | |
this.on('success', function(file, data) { | |
console.log(file, data); | |
}); | |
... lines 79 - 84 | |
} | |
}); | |
} |
Ok, refresh, select any file and... in the console... nice! We already did the work of returning the new ArticleReference
JSON on success... even though we didn't need it before. Thanks past us!
And now, we're dangerous. If we can somehow take that data, put it into the references
property in our class and re-render, we'll be good!
To help that, add a new function called addReference()
. This will take in a new reference and then push it onto this.references
. Then call this.render()
.
... lines 1 - 34 | |
class ReferenceList | |
{ | |
... lines 37 - 49 | |
addReference(reference) { | |
this.references.push(reference); | |
this.render(); | |
} | |
... lines 54 - 69 | |
} | |
... lines 71 - 96 |
For people that are used to React, I do want to mention two things. First, we're mutating, um, changing the this.references
property when we say this.references.push()
. Changing "state", which is basically what this is, is a big "no no" in React. But in our simpler system, it's fine. Second, each time we call this.render()
, it is completely emptying the ul
and re-adding all the HTML from scratch. Front-end frameworks like React or Vue are way smarter than this and are able to update just the pieces that changed.
Anyways, inside of initializeDropzone()
, add a referenceList
argument: we're going to force this to get passed to us. I'll even document that this will be an instance of the ReferenceList
class.
... lines 1 - 71 | |
/** | |
* @param {ReferenceList} referenceList | |
*/ | |
function initializeDropzone(referenceList) { | |
... lines 76 - 94 | |
} |
Back on top, pass in the object - referenceList
.
... lines 1 - 2 | |
$(document).ready(function() { | |
... lines 4 - 5 | |
initializeDropzone(referenceList); | |
... lines 7 - 31 | |
}); | |
... lines 33 - 96 |
And now inside success, instead of console.log()
, we'll say referenceList.addReference(data)
.
... lines 1 - 74 | |
function initializeDropzone(referenceList) { | |
... lines 76 - 80 | |
var dropzone = new Dropzone(formElement, { | |
... line 82 | |
init: function() { | |
this.on('success', function(file, data) { | |
referenceList.addReference(data); | |
}); | |
... lines 87 - 92 | |
} | |
}); | |
} |
Cool! Give your page a nice refresh. And... let's see: astronaut.jpg
is the last file on the list currently. So let's upload Earth from the Moon.jpeg
. It uploads and... boom! So fast! We can even instantly downloaded it.
Next: let's keep leveling up: authors need a way to delete existing file references.
Hey Steve D.
Sorry but I just don't understand your problem. Could you elaborate it a bit more? What error are you experiencing? What have you debugged already? What's your final goal?
Cheers!
All sorted Diego. Your help on my other issue regarding the private files lead me straight to the answer to this one. I juts need the actual url to the "download" end point . and not the twig code for it.
Thank you again
Steve
Hi, is posbile with dropzone o fileType of symfony . read the content the file then save each one in a entity Post ?, the client need insert 600 post at day from the file, how can make this? plus this filetype live othes field like texttype and datetime, like form with children
Hi @sansxd!
Hmm. So, once a file is uploaded (via Dropzone or the FileType), you ultimately end up with an UploadedFile object. Usually you then move the file to some final location, like we've done. That object has a method called ->getContent()
. So, instead of moving the file, you could call that, get the contents, then set that onto a new Post
entity :).
Let me know if that helps!
return
<li class="list-group-item d-flex justify-content-between align-items-center">
${reference.originalFilename}
<span>
<a href="/admin/article/references/${reference.id}/download"><span class="fa fa-download"></span></a>
</span>
</li>
You're introducing an XSS vulnerability here. Unless Symfony does anything special to the original file name, it'll be whatever the user chooses, even if that includes special characters like < or " that are needed to introduce onwhatever="" attributes or script tags to the page.
Hey there,
Thanks for pointing it out. For XSS attacks you can use Symfony's CSRF protection on your forms. In this case, you'd have to rename the file before saving it on your server
Cheers!
After adding the class ReferenceList and putting the var Referencelist inside the document.ready function, I got this error in the console:
ReferenceError: ReferenceList is not defined admin_article_form.js:3:9
After putting it outside the document.ready, after the class, it worked, am I the only one? A wrong scope?
EDIT: no luck, later on same thing happens when trying to pass back the updated references.
EDIT: My fault, I had put the function initializeDropzone within the document.ready
EDIT: After putting everything in the right place, there is no feedback in the client side list when a new file was uploaded, in the console: "TypeError: referenceList.addReference
is not a function"
What could be wrong?
Hey Cavisv,
I'm glad you was able to fix your previous issues, well done!
About the "TypeError: referenceList.addReference is not a function" - hm, you probably need to debug it more, you can call "console.log(referenceList);" right before "referenceList.addReference()" call. Does it referenceList object? Probably you're passing the wrong object to the initializeDropzone() function.
Cheers!
thanks for the feedback, it was just a missing function :d
`addReference(reference) {
this.references.push(reference);
this.render();
}`
now it works
// composer.json
{
"require": {
"php": "^7.1.3",
"ext-iconv": "*",
"aws/aws-sdk-php": "^3.87", // 3.87.10
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"knplabs/knp-markdown-bundle": "^1.7", // 1.7.1
"knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
"knplabs/knp-time-bundle": "^1.8", // 1.9.0
"league/flysystem-aws-s3-v3": "^1.0", // 1.0.22
"league/flysystem-cached-adapter": "^1.0", // 1.0.9
"liip/imagine-bundle": "^2.1", // 2.1.0
"nexylan/slack-bundle": "^2.0,<2.2.0", // v2.1.0
"oneup/flysystem-bundle": "^3.0", // 3.0.3
"php-http/guzzle6-adapter": "^1.1", // v1.1.1
"sensio/framework-extra-bundle": "^5.1", // v5.2.4
"stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
"symfony/asset": "^4.0", // v4.2.3
"symfony/console": "^4.0", // v4.2.3
"symfony/flex": "^1.9", // v1.17.6
"symfony/form": "^4.0", // v4.2.3
"symfony/framework-bundle": "^4.0", // v4.2.3
"symfony/orm-pack": "^1.0", // v1.0.6
"symfony/security-bundle": "^4.0", // v4.2.3
"symfony/serializer-pack": "^1.0", // v1.0.2
"symfony/twig-bundle": "^4.0", // v4.2.3
"symfony/validator": "^4.0", // v4.2.3
"symfony/web-server-bundle": "^4.0", // v4.2.3
"symfony/yaml": "^4.0", // v4.2.3
"twig/extensions": "^1.5" // v1.5.4
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.0", // 3.1.0
"easycorp/easy-log-handler": "^1.0.2", // v1.0.7
"fzaninotto/faker": "^1.7", // v1.8.0
"symfony/debug-bundle": "^3.3|^4.0", // v4.2.3
"symfony/dotenv": "^4.0", // v4.2.3
"symfony/maker-bundle": "^1.0", // v1.11.3
"symfony/monolog-bundle": "^3.0", // v3.3.1
"symfony/phpunit-bridge": "^3.3|^4.0", // v4.2.3
"symfony/profiler-pack": "^1.0", // v1.0.4
"symfony/var-dumper": "^3.3|^4.0" // v4.2.3
}
}
Instead of references my list is images. How would I be able to output this:
`<a href="{{ uploaded_asset(image.imagePath) }}">
</a>
`
in the:
render() {