Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Reordering Endpoint & AJAX

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

Let's upload all of these files. How nice is that? One fails because it's the wrong type and another fails because it's too big. But we get nice errors and all the rest worked. And this gives us a lot more to play with for reordering!

Getting the Sorted Ids

To make an AJAX call when we finishing dragging, add a new option: onEnd set to an arrow function. Inside console.log(this.sortable) - that's the sortable object we stored earlier .toArray().

... lines 1 - 34
class ReferenceList
{
constructor($element) {
... line 38
this.sortable = Sortable.create(this.$element[0], {
... lines 40 - 41
onEnd: () => {
console.log(this.sortable.toArray());
}
});
... lines 46 - 62
}
... lines 64 - 117
}
... lines 119 - 144

Check it out: refresh the page, drag one of these... and go look at the console. Woh! Those are the reference ids... in the right order! Try it again: move this one up and... yep! The id 11 just moved up a few spots.

But... how the heck is this working? How does sortable know what the ids are? Well, honestly... we got lucky. It knows thanks to the data-id attribute that we put on each li! We added that for our own JavaScript... but the Sortable library also knows to read that!

The Reorder Endpoint

This is amazing! This is the exact data we need to send to the server! Open up ArticleReferenceAdminController and find downloadArticleReference(). If you look closely, about half of the methods in this controller have an {id} route wildcard where the id is for an ArticleReference. Those endpoints are actions that operating on a single item. The other half of the endpoints, the ones on top, also have an {id} wildcard, but these are for the Article.

What about our new endpoint? We'll be reordering all of the references for one article... so it's a bit more like these ones on top. Copy this entire action for getting article references, change the name to reorderArticleReferences and put /reorder on the URL. Make this a method="POST" and name it admin_article_reorder_references.

... lines 1 - 20
class ArticleReferenceAdminController extends BaseController
{
... lines 23 - 93
/**
* @Route("/admin/article/{id}/references/reorder", methods="POST", name="admin_article_reorder_references")
* @IsGranted("MANAGE", subject="article")
*/
public function reorderArticleReferences(Article $article)
{
return $this->json(
$article->getArticleReferences(),
200,
[],
[
'groups' => ['main']
]
);
}
... lines 109 - 184
}

If you're wondering about the URL or the method POST, well, this endpoint isn't very RESTful.. it doesn't fit into the nice create-read-update-delete model... and that's ok. Usually when I have a weird endpoint like this, I use POST.

Inside the method, here's the plan: our JavaScript will send a JSON body containing an array of the ids in the right order. This array exactly. Add the Request argument so we can get read that data and the EntityManagerInterface so we can save stuff.

... lines 1 - 20
class ArticleReferenceAdminController extends BaseController
{
... lines 23 - 97
public function reorderArticleReferences(Article $article, Request $request, EntityManagerInterface $entityManager)
{
... lines 100 - 121
}
... lines 123 - 198
}

To decode the JSON this time, it's so simple! I'm going to skip using Symfony's serializer. Say $orderedIds = json_decode() passing that $request->getContent() and true so it gives us an associative array.

... lines 1 - 20
class ArticleReferenceAdminController extends BaseController
{
... lines 23 - 97
public function reorderArticleReferences(Article $article, Request $request, EntityManagerInterface $entityManager)
{
$orderedIds = json_decode($request->getContent(), true);
... lines 101 - 121
}
... lines 123 - 198
}

Then, if orderedIds === false, something went wrong. Let's return this->json() and, to at least somewhat match the validation responses we've had so far, let's set a detail key to, how about, Invalid body with 400 for the status code.

... lines 1 - 20
class ArticleReferenceAdminController extends BaseController
{
... lines 23 - 97
public function reorderArticleReferences(Article $article, Request $request, EntityManagerInterface $entityManager)
{
$orderedIds = json_decode($request->getContent(), true);
if ($orderedIds === null) {
return $this->json(['detail' => 'Invalid body'], 400);
}
... lines 105 - 121
}
... lines 123 - 198
}

Using the Ordered Ids to Update the Database

Ok, cool: we've got the array of ids in the new order we want. Use this to say $orderedIds = array_flip($orderedIds). This deserves some explanation. The original array is a map from the position to the id - the keys are 0, 1, 2, 3 and so on. After the flip, we have a very handy array: the key is the id and the value is its new position.

... lines 1 - 20
class ArticleReferenceAdminController extends BaseController
{
... lines 23 - 97
public function reorderArticleReferences(Article $article, Request $request, EntityManagerInterface $entityManager)
{
$orderedIds = json_decode($request->getContent(), true);
if ($orderedIds === null) {
return $this->json(['detail' => 'Invalid body'], 400);
}
// from (position)=>(id) to (id)=>(position)
$orderedIds = array_flip($orderedIds);
... lines 108 - 121
}
... lines 123 - 198
}

To use this, foreach over $article->getArticleReferences() as $reference. And inside, $reference->setPosition() passing this $orderedIds[$reference->getId()] to look up the new position.

... lines 1 - 20
class ArticleReferenceAdminController extends BaseController
{
... lines 23 - 97
public function reorderArticleReferences(Article $article, Request $request, EntityManagerInterface $entityManager)
{
$orderedIds = json_decode($request->getContent(), true);
if ($orderedIds === null) {
return $this->json(['detail' => 'Invalid body'], 400);
}
// from (position)=>(id) to (id)=>(position)
$orderedIds = array_flip($orderedIds);
foreach ($article->getArticleReferences() as $reference) {
$reference->setPosition($orderedIds[$reference->getId()]);
}
... lines 111 - 121
}
... lines 123 - 198
}

And yes, we could code more defensively - like checking to make sure each array key was actually sent. And I would do that if this were a public API that other people used, or if invalid data could cause some harm.

Anyways, at the bottom, save: $entityManager->flush().

... lines 1 - 20
class ArticleReferenceAdminController extends BaseController
{
... lines 23 - 97
public function reorderArticleReferences(Article $article, Request $request, EntityManagerInterface $entityManager)
{
$orderedIds = json_decode($request->getContent(), true);
if ($orderedIds === null) {
return $this->json(['detail' => 'Invalid body'], 400);
}
// from (position)=>(id) to (id)=>(position)
$orderedIds = array_flip($orderedIds);
foreach ($article->getArticleReferences() as $reference) {
$reference->setPosition($orderedIds[$reference->getId()]);
}
$entityManager->flush();
... lines 113 - 121
}
... lines 123 - 198
}

Sending the AJAX Request

Ok, let's hook up the JavaScript! Back in admin_article_form.js, scroll up... let's see - find the onEnd() of sortable. Say $.ajax() and give this the url key. For the URL, remember, the ul element has a data-url attribute, which is the path to the admin_article_list_references route, so /admin/article/{id}/references. Not by accident, the URL that we want is that plus /reorder.

... lines 1 - 34
class ReferenceList
{
constructor($element) {
... lines 38 - 41
onEnd: () => {
$.ajax({
... lines 44 - 46
});
}
});
... lines 50 - 66
}
... lines 68 - 121
}
... lines 123 - 148

So let's do a little bit of code re-use... and a little bit of hardcoding: in general, I don't worry too much about hardcoding URLs in JavaScript. Copy this.$element.data('url') from below, paste, and add /reorder. Then, method set to POST and data set to JSON.stringify(this.sortable.toArray()).

... lines 1 - 34
class ReferenceList
{
constructor($element) {
... lines 38 - 41
onEnd: () => {
$.ajax({
url: this.$element.data('url')+'/reorder',
method: 'POST',
data: JSON.stringify(this.sortable.toArray())
});
}
});
... lines 50 - 66
}
... lines 68 - 121
}
... lines 123 - 148

Ok, let's do this! Move over and refresh. No errors yet... Move "astronaut-1.jpg" down two spots and... hey! A 200 status code on that AJAX request! That's a good sign. Refresh and... aw! It's right back up on top!

Changing the Endpoint Order

Oh wait... the problem is that we're not rendering the list correctly! This list loads by making an Ajax request. In the controller... here's the endpoint: getArticleReferences(). And it gets the data from $article->getArticleReferences(). The problem is that this method doesn't know that it should order the reference's by position.

Open up the Article entity and, above $articleReferences, add @ORM\OrderBy({"position"="ASC"}).

... lines 1 - 18
class Article
{
... lines 21 - 89
/**
* @ORM\OneToMany(targetEntity="App\Entity\ArticleReference", mappedBy="article")
* @ORM\OrderBy({"position"="ASC"})
*/
private $articleReferences;
... lines 95 - 323
}

Let's go check out the endpoint: I'll click to open the URL in a new tab. Woohoo! astronaut-1.jpg is third! Refresh the main page. Boom! The astronaut is right were we sorted it. Let's move it down a bit further... move the Symfony Best Practices up from the bottom and refresh. The sorting sticks. Awesome!

Next, instead of saving the uploaded files locally, let's upload them to AWS S3.

Leave a comment!

4
Login or Register to join the conversation
Sylvain C. Avatar
Sylvain C. Avatar Sylvain C. | posted 2 years ago

Hello SFcasts team ! thx for this course. I have a question about assets paths.
In fact, i don't want to use S3, but actually when i want to show references in frontend, i don't success with traditionnal {{ asset(' ') }} code.
Because we are storing data in var directory, i thinks it's not possible to access in frontend.
Have you got a solution to fix this that ? In next chapter you are speaking about assets path but it's for S3.

Sorry for my english form Nice city (FR).

Reply

Hey Sylvain C.

What kind of assets do you have? I mean it's not a common situation when you are using var/ directory for public static assets. But if you have a private assets it was described in chapter 23/24. Oh and yes you can't use {{ asset() }} for it, you should create a public endpoint and use {{ path|url() }} functions

Cheers!

Reply
Anton B. Avatar
Anton B. Avatar Anton B. | posted 3 years ago

Sorry, but what about the real multiple files uploading?The Dropzone.js uploads the files one by one. Can you please advice how to count then the uploaded files (at once) on the server side?

Reply

Hey Anton,

I see it's a duplicate of https://symfonycasts.com/sc... - let's follow the conversation there as it's related to Dropzone. We will try to answer your question soon.

Cheers!

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