Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Rendering the File List Client Side

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 $10.00

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

Login Subscribe

Here'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!

Article References Collection Endpoint

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.

JavaScript Rendering

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

The ReferenceList JavaScript Class

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.

Dynamically Adding the Row

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.

Leave a comment!

10
Login or Register to join the conversation
Steve-D Avatar
Steve-D Avatar Steve-D | posted 4 years ago | edited

Instead of references my list is images. How would I be able to output this:

`<a href="{{ uploaded_asset(image.imagePath) }}">

<img src="{{ image.imagePath|imagine_filter('squared_thumbnail_medium') }}" />

</a>`
in the:
render() {

const itemsHtml = this.images.map(image => {
    return```
1 Reply

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!

-1 Reply
Steve-D Avatar

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

1 Reply

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

Reply

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!

Reply
Default user avatar
Default user avatar vuln | posted 2 years ago | edited

return
        &lt;li class="list-group-item d-flex justify-content-between align-items-center">
            ${reference.originalFilename}
        
            &lt;span>
                &lt;a href="/admin/article/references/${reference.id}/download"><span class="fa fa-download"></span></a>
            &lt;/span>
        &lt;/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 &lt; or " that are needed to introduce onwhatever="" attributes or script tags to the page.

Reply

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!

Reply
Peter V. Avatar
Peter V. Avatar Peter V. | posted 4 years ago | edited

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?

Reply

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!

1 Reply
Peter V. Avatar
Peter V. Avatar Peter V. | Victor | posted 4 years ago | edited

thanks for the feedback, it was just a missing function :d

`addReference(reference) {
this.references.push(reference);

    this.render();
}`

now it works

Reply
Cat in space

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

This tutorial is built on Symfony 4 but works great in Symfony 5!

What PHP libraries does this tutorial use?

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