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 SubscribeTo make this all work, but to avoid going totally insane and coding JavaScript for the next 30 minutes, we're going to turn the printed string into an input text body and, on "blur" - so when we click away from it, we'll make an AJAX request to save the new filename.
Let's copy the original filename code and replace it with <input type="text"
and value="
that original filename stuff. Let's also add two classes: one from Bootstrap to make things look nice and another - js-edit-filename
- so that we can find this field in JavaScript. Oh, one more detail: add a style
attribute with width: auto
- just another styling thing.
... lines 1 - 34 | |
class ReferenceList | |
{ | |
... lines 37 - 92 | |
render() { | |
const itemsHtml = this.references.map(reference => { | |
return ` | |
<li class="list-group-item d-flex justify-content-between align-items-center" data-id="${reference.id}"> | |
<input type="text" value="${reference.originalFilename}" class="form-control js-edit-filename" style="width: auto;"> | |
... lines 99 - 103 | |
</li> | |
` | |
}); | |
... lines 107 - 108 | |
} | |
} | |
... lines 111 - 136 |
Next: copy the js-
class name and head back up to the constructor. We're going to do the same thing we did with our delete link: this.$element.on('blur')
, this time with .js-edit-filename
and then our arrow function. Inside that, call a new function: this.handleReferenceEditFilename()
and pass that the event
.
... lines 1 - 34 | |
class ReferenceList | |
{ | |
constructor($element) { | |
... lines 38 - 45 | |
this.$element.on('blur', '.js-edit-filename', (event) => { | |
this.handleReferenceEditFilename(event); | |
}); | |
... lines 49 - 55 | |
} | |
... lines 57 - 109 | |
} | |
... lines 111 - 136 |
Keep going: copy the method name, scroll down a bit, and create that function, which will accept an event
object. Let's also steal the first two lines from handleReferenceDelete()
: we're going to start the exact same way.
... lines 1 - 34 | |
class ReferenceList | |
{ | |
... lines 37 - 78 | |
handleReferenceEditFilename(event) { | |
const $li = $(event.currentTarget).closest('.list-group-item'); | |
const id = $li.data('id'); | |
... lines 82 - 91 | |
} | |
... lines 93 - 109 | |
} | |
... lines 111 - 136 |
Heck, we're going to make an AJAX request to the same URL! Just with the PUT
method insteadof DELETE
.
When we send that AJAX request, we're only going to send one piece of data: the originalFilename
that's in the text box. But I want you to pretend that we're allowing multiple fields to be updated on the reference. So, more abstractly, what we were really want to do is find the reference that's being updated from inside this.references
, change the originalFilename
data on it, JSON-encode that entire object, and send it to the endpoint.
If that doesn't make sense yet, don't worry. To find the reference object that's being updated right now, say const reference = this.references.find()
and pass this an arrow function with a reference argument. Inside, return reference.id === id
.
... lines 1 - 34 | |
class ReferenceList | |
{ | |
... lines 37 - 78 | |
handleReferenceEditFilename(event) { | |
const $li = $(event.currentTarget).closest('.list-group-item'); | |
const id = $li.data('id'); | |
const reference = this.references.find(reference => { | |
return reference.id === id; | |
}); | |
... lines 85 - 91 | |
} | |
... lines 93 - 109 | |
} | |
... lines 111 - 136 |
This loops over all the references and returns the first one it finds that matches the id... which should only be one. Now change the originalFilename
property to $(event.currentTarget)
- that will give us the input element - .val()
.
... lines 1 - 34 | |
class ReferenceList | |
{ | |
... lines 37 - 78 | |
handleReferenceEditFilename(event) { | |
const $li = $(event.currentTarget).closest('.list-group-item'); | |
const id = $li.data('id'); | |
const reference = this.references.find(reference => { | |
return reference.id === id; | |
}); | |
reference.originalFilename = $(event.currentTarget).val(); | |
... lines 86 - 91 | |
} | |
... lines 93 - 109 | |
} | |
... lines 111 - 136 |
Ok! We're ready to send the AJAX request! Copy the first-half of the AJAX call from the delete function, remove the .then()
stuff, change the method to PUT
and, for the data, just pass reference
.
... lines 1 - 34 | |
class ReferenceList | |
{ | |
... lines 37 - 78 | |
handleReferenceEditFilename(event) { | |
const $li = $(event.currentTarget).closest('.list-group-item'); | |
const id = $li.data('id'); | |
const reference = this.references.find(reference => { | |
return reference.id === id; | |
}); | |
reference.originalFilename = $(event.currentTarget).val(); | |
$.ajax({ | |
url: '/admin/article/references/'+id, | |
method: 'PUT', | |
data: reference | |
}); | |
} | |
... lines 93 - 109 | |
} | |
... lines 111 - 136 |
There is a small problem with this - so if you see it, hang on! But, the idea is cool: we're sending up all of the reference data. And yes, this will send more fields than we need, but that's ok! The deserializer just ignores that extra stuff.
Testing time! Refresh the whole page. Oh wow - we have an extra <
sign! As cool as that looks, let's scroll down to render and... there it is - remove that.
Refresh again. Let's tweak the filename and then click off to trigger the "blur". Uh oh!
Cannot set property
originalFilename
of undefined.
Hmm. Look back at our code: for some reason it's not finding our reference. Oh, duh: return referenced.id === id
.
Ok, let's see if I've finally got everything right. Refresh, add a dash to the filename, click off and... 500 error! That's progress! Open the profiler for that request in a new tab. Ok: a "Syntax Error" coming from a JsonDecode
class. Oh, and look at the data that's passed to the deserialize()
function! That's not JSON!
Silly mistake. When we set the data
key to the reference
object, jQuery doesn't send up that data as JSON, it uses the standard "form submit" format. We want JSON.stringify(reference)
.
... lines 1 - 34 | |
class ReferenceList | |
{ | |
... lines 37 - 78 | |
handleReferenceEditFilename(event) { | |
... lines 80 - 86 | |
$.ajax({ | |
... lines 88 - 89 | |
data: JSON.stringify(reference) | |
}); | |
} | |
... lines 93 - 109 | |
} | |
... lines 111 - 136 |
I think we've got it this time. Refresh, tweak the filename, click off and... no errors! Check out the network tab. Yeah 200
! The response returns the updated originalFilename
and, if you scroll down to the request body... cool! You can see the raw JSON that was sent up.
The last thing we need to do is... add validation. I know, it's always that annoying last detail once you've got the "happy" path working perfectly. But, right now, we could leave the filename completely blank and our system would be ok with that. Well ya know what? I am totally not ok with that!
Ultimately, our endpoint modifies the ArticleReference
object and that is what we should validate. Above the originalFilename
field, add @NotBlank()
and let's also use @Length()
. The length can be 255 in the database, but let's use max=100
.
... lines 1 - 7 | |
use Symfony\Component\Validator\Constraints as Assert; | |
... lines 9 - 12 | |
class ArticleReference | |
{ | |
... lines 15 - 34 | |
/** | |
... lines 36 - 37 | |
* @Assert\NotBlank() | |
* @Assert\Length(max=100) | |
*/ | |
private $originalFilename; | |
... lines 42 - 103 | |
} |
Then, inside our endpoint, there's no form here, but that's fine. Add the ValidatorInterface $validator
argument. And right after we update the object with the serializer, add $violations = $validator->validate()
and pass it the $reference
object. Then if $violations->count() > 0
, return $this->json($violations, 400)
.
... lines 1 - 20 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
... lines 23 - 136 | |
public function updateArticleReference(ArticleReference $reference, EntityManagerInterface $entityManager, SerializerInterface $serializer, Request $request, ValidatorInterface $validator) | |
{ | |
... lines 139 - 151 | |
$violations = $validator->validate($reference); | |
if ($violations->count() > 0) { | |
return $this->json($violations, 400); | |
} | |
... lines 156 - 167 | |
} | |
} |
We're actually not going to handle that in JavaScript - I'll leave rendering the errors up to you - you could highlight the element in red and print the error below... whatever you want.
But let's at least make sure it works. Clear out the filename, hit tab to blur and... there it is! A 400 error with our beautiful error response. To handle this in JavaScript, you'll chain a .catch()
onto the end of the AJAX call and then do whatever you want.
Ok, what else can we add to our upload widget? How about the ability to reorder the list. That's next.
Hey Peter V.!
Sorry for the slow reply - we had a problem with our comment notification system :).
Because you've verified that the reference
in JavaScript contains the new originalFilename, the problem is likely on the server (in the controller) with updating the data onto the object. I'm not sure what the problem might be - I'd check your code there (or post it here). It's possible there's a validation error... or something else. The fact that the JSON response returns the old originalFilename tells me that the problem is likely not saving it to the database - but actually in the deserialization process.
Cheers!
This is the controller code, do you think there might be something wrong there?
/**
* @Route("/admin/article/references/{id}", name="admin_article_update_reference", methods={"PUT"})
*/
public function updateArticleReference(
ArticleReference $reference,
UploaderHelper $uploaderHelper,
EntityManagerInterface $entityManager,
SerializerInterface $serializer,
Request $request,
ValidatorInterface $validator
) {
$violations = $validator->validate($reference);
if($violations->count()>0) {
return $this->json($violations,400);
}
$article = $reference->getArticle();
// dd($article); gives uninitialized Article with the id of the url
$this->denyAccessUnlessGranted("MANAGE", $article);
$serializer->deserialize(
$request->getContent(),
ArticleReference::class,
'json',
[
'object_to_populate' => $reference,
'groups' => [ 'input' ]
]
);
// dd($reference); gives unchanged filename
$entityManager->persist($reference);
$entityManager->flush();
return $this->json(
$reference,
200,
[],
[
'groups' => 'main'
]
);
}
Found my mistake!
It was the security setting on the entity, on the wrong property:
` /**
* @ORM\Column(type="string", length=255)
* @Groups({"main", "input"})
*/
private $filename;
/**
* @ORM\Column(type="string", length=255)
* @Groups("main")
* @Assert\NotBlank()
* @Assert\Length(max=100)
*/
private $originalFilename;
`Instead of:
` /**
* @ORM\Column(type="string", length=255)
* @Groups("main")
*/
private $filename;
/**
* @ORM\Column(type="string", length=255)
* @Groups({"main", "input"})
* @Assert\NotBlank()
* @Assert\Length(max=100)
*/
private $originalFilename;`
Sorry for bothering you!
// 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
}
}
Hi, I don't seem to get it to work.
When doing console.log like below, it does return the changed originalFilename.
However it does not saves it and there are no errors in the console.
The xhr header gives 200 with the old originalFilename.
Any ideas?
`handleReferenceEditFilename(event) {
}`