Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Upload Field Styling & Bootstrap

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

If you use the Bootstrap 4 theme with Symfony... things get weird with upload fields! Yea, there is a good reason for why, but out-of-the-box, it's... just super weird. The problem? Select a file and... get rewarded by seeing absolutely nothing! Did the file actually attach? We should see the filename somewhere. What happened?

Why Doesn't it Work?

The thing is... styling a file upload field is kinda hard. So, if you really want to control how it looks and make it super shiny, Bootstrap allows you to create a "custom" file input structure, which is what Symfony uses by default. Check this out: see the <input type="file"...> field? That's hidden by Bootstrap! Try removing the opacity: 0 part and... say hello to the real file upload field... with the filename that we selected!

Bootstrap hides the input so that it, or we, can completely control how this whole field looks. Everything you actually see comes from the label: it takes up the entire width. Even the "Browse" button comes from some :after content.

The great thing about this is that styling a label element is easy. The sad panda part is that we don't see the filename when we select a file! We can fix that - but it takes a little bit of JavaScript.

Customizing the Text in the Upload Field

Before we do that, we can also put a message in the main part of the file field by putting some content in the label element. But... it doesn't work like a normal label.

In the templates/ directory, open article_admin/_form.html.twig. Here's our imageFile field. The second argument to form_row is an array of variables you can use to customize... basically anything. One of the most important ones is called attr: it's how you attach custom HTML attributes to the input field. Pass an attribute called placeholder set to Select an article image.

{{ form_start(articleForm) }}
... lines 2 - 4
{{ form_row(articleForm.imageFile, {
attr: {
'placeholder': 'Select an article image'
}
}) }}
... lines 10 - 27
{{ form_end(articleForm) }}

This would normally add a placeholder attribute to the input so you can have some text on the field if it's empty. But when you're dealing with a file upload field with the Bootstrap theme, this is used in a different way... but it accomplishes the same thing.

Refresh! Cool! The empty part of the file field now gets this text.

Showing the Selected Filename

But if you select a file... the filename still doesn't show. Let's fix that already. Look at the structure again: Symfony's form theme is using this custom-file-input class on the input. Ok, so what we need to do is this: on change of that field, we need to set the HTML of the label to the filename, which is something we have access to in JavaScript.

To keep things simple, open base.html.twig: we'll write some JavaScript that will work across the entire site. I'd recommend using Webpack Encore, and putting this code in your main entry file if you want it to be global. But, without Encore, down here works fine.

Use $('.custom-file-input') - that's the class that's on the input field itself, .on('change') and pass this a callback with an event argument. Inside, we need to find the label element: I'll do that by finding the parent of the input and then looking for the custom-file-label class so we can set its HTML.

... line 1
<html lang="en">
... lines 3 - 15
<body>
... lines 17 - 82
{% block javascripts %}
... lines 84 - 86
<script>
... line 88
$('.custom-file-input').on('change', function(event) {
... lines 90 - 93
});
</script>
{% endblock %}
</body>
</html>

In the callback, set var inputFile = event.currentTarget - that's the DOM node for the input type="file" element. Next, $(inputFile).parent().find('.custom-file-label').html() and pass this the filename that was just selected: inputFile.files[0].name. The 0 part looks a bit weird, but technically a file upload field can upload multiple files. We're not doing that, so we get to take this shortcut.

... line 1
<html lang="en">
... lines 3 - 15
<body>
... lines 17 - 82
{% block javascripts %}
... lines 84 - 86
<script>
... line 88
$('.custom-file-input').on('change', function(event) {
var inputFile = event.currentTarget;
$(inputFile).parent()
.find('.custom-file-label')
.html(inputFile.files[0].name);
});
</script>
{% endblock %}
</body>
</html>

Give it a try! Refresh... browse... select rocket.jpg and... yea! Our placeholder gets replaced by the filename. That's what we expect and the field is easier to style thanks to this.

Next: the upload side of things is looking good. It's time to start rendering the URL to the upload files... but without letting things get crazy-disorganized. I want to love our setup.

Leave a comment!

22
Login or Register to join the conversation
Mamour W. Avatar
Mamour W. Avatar Mamour W. | posted 3 years ago | edited

A small contribution:
The javascript code to change the placeholder don't always work, when click on browse and dont actually choose a file the place holder wont change because inputFile.files[0].name is not define and will throw an exception I fixed it by counting the number of files selected and if its zero then put "select a file instead of the file name.


$('.custom-file-input').on('change', function(event) {
            var inputFile = event.currentTarget;
            if(inputFile.files.length===0){
                $(inputFile).parent()
                    .find('.custom-file-label')
                    .html("Select one image");
            }else {
                $(inputFile).parent()
                    .find('.custom-file-label')
                    .html(inputFile.files[0].name);
            }

        });

Bonus, for a multiple file field you should use this one :


$('.custom-file-input').on('change', function(event) {
            var inputFile = event.currentTarget;
            if(inputFile.files.length===0){
                $(inputFile).parent()
                    .find('.custom-file-label')
                    .html("Select one or many images");
            }else {
                var placeholder = "";
                $.each(inputFile.files,function (index , value) {
                    placeholder += value.name+" ";
                });
                $(inputFile).parent()
                    .find('.custom-file-label')
                    .html(placeholder);
            }
        });

<b>It Rocks #RyanVoice
</b>

1 Reply

Hey Mamour W.!

Nice catch on that edge case! I see it now: if I (A) select a file and then (B) go back and select NO file, then the change event is triggered, but the files are empty. Excellent catch - thanks for sharing that :).

It Rocks #RyanVoice

😂

Cheers!

1 Reply
Jeff Avatar

Is there a way to get the upload field to display the value of a currently uploaded image (which, in this case, would be a string representing an image path, in my case), before clicking "Choose File" and replacing the currently uploaded image? Can a FileType field store a string value for a currently uploaded URL?

Reply

Hey @Jeff!

Hmm. Part of this answer depends on exactly WHERE you want to show the filename. Fore example, on solution would be to do a "form theme" for just this one field - e.g. - https://symfonycasts.com/screencast/symfony-forms/custom-field-theme - or render the field a bit more manually - something like this:

<div>
    {{ form_label(articleForm.imageFilename) }}
    {{ form_widget(articleForm.imageFilename) }}
    {% if articleForm.imageFilename.vars.data %}
        ({{ articleForm.imageFilename.vars.data }})
    {% endif %}
    {{ form_errors(articleForm.imageFilename) }}
</div>

(the trick here is getting all the styling / CSS classes correct since you're taking more control over things).

OR, you might want to do something a bit more similar to what we do in the video: where we replace the label with the current filename. In that case, it can be done when rendering the field:

{{ form_row(articleForm.imageFile, {
    label: articleForm.imageFilename.vars.data ?: 'Choose an image'
}) }}

Let me know if that helps :)

Cheers!

Reply
Default user avatar
Default user avatar Insert name here | posted 2 years ago | edited

$(inputFile).parent().find('.custom-file-label').html()<br />

Shouldn't that be .text()?

Reply

Hey there!

Yea, I think you're right, since we're not injecting any HTML content, it would be safer to use the .text() function

Cheers!

Reply
Default user avatar
Default user avatar Toavina Ralambosoa | posted 3 years ago

Really thanks, save my day

Reply

Hey TomaszGasior!

You're right! But in this case, because I'm writing this code in my template (and not in a system where it will be compiled/transpiled with Babel), I'm being conservative: about 5% of browsers still don't support "var". So it's "probably" safe to use on my sites... but I'm being conservative. Of course, inside a Webpack-powered system, it's fine because if you need to support older browser, you can allow it to rewrite that code for you.

Cheers!

Reply

Probably you are right but please consult https://caniuse.com/#feat=let . For my country it shows 95% coverage. :)

Reply

Yea, you may want to ignore that missing 5% but as Ryan said, the best way would be to use Encore Webpack and let it compile your javascript

Reply

Hello Ryan,

sorry to disturb.

I would like to customize also the label of the file field (ie the "Browse" button) changing the text and adding a on hover effect, but I have big difficulties in finding where and what should change.

can you please just give me a hint in the right direction?

thank you for your attention and for the great job you do!

cheers

Reply

Hey chieroz

Nice question! You made me dig :)

What's happening here is that Bootstrap4 adds the text of the button via CSS. If you inspect that element, you will notice that the button is added via a pseudo-element (check the "custom-file-label" CSS class). What you can do is to override it by defining your own CSS rules for that class, or do it via Javascript (ugly)

Cheers!

Reply

Hello!

I have added as mimeTypes "text/csv" but validation failed even if the file is a csv file.

Error displayed:
The mime type of the file is invalid ("text/plain"). Allowed mime types are "text/csv".

If I dd my $uploadedFile variable, I can see that the mimeType value is set to "text/csv". Any idea?

Thx!

Reply

Hey be_tnt

How are you uploading your file? Remember that you need to specify the encryption type of your form as multipart/form-data. Also, just in case, double check your submitted file :)

Cheers!

Reply

Hi Diego,

I am using the FileType element so the multipart/form-data is added automatically to the form.

Cheers!

Reply

Oh, I see, that's even better :)
but then, why it's not working? Are you submitting your form via JS? I may need to see your code, if you can upload it to Github, so I can give it a look

Reply

Hello!
I do not have the code on Github. Let me try to give you as much information I can :)

In my controller:

`public function upload(Request $request, UploaderHelper $uploaderHelper, ValidatorInterface $validator)

	{
		$form = $this->createForm(UploadProspectType::class);

		$form->handleRequest($request);
		if ($form->isSubmitted() && $form->isValid()) {

			/**
			 * @var UploadedFile $uploadedFile
			 */
			$uploadedFile = $form['prospectsFile']->getData();

			$violations = $validator->validate(
				$uploadedFile,
				new File([
					'mimeTypes' => [
						'text/csv',
					]
				])
			);

			if ($violations->count() > 0) {
				/** @var ConstraintViolation $violation */
				$violation = $violations[0];

				$this->addFlash('error', $violation->getMessage());

				return $this->render('prospect/upload.html.twig', [
					'form' => $form->createView()
				]);
			}
			if ($uploadedFile) {
				$newFilename      = $uploaderHelper->uploadCSVFile($uploadedFile);
			}
		}

		return $this->render('prospect/upload.html.twig', [
			'form' => $form->createView()
		]);
	}`

In the form type:

public function buildForm(FormBuilderInterface $builder, array $options)
` {

		$builder
			->add('prospectsFile', FileType::class, [
				'label' => $this->translator->trans('form.file',[], 'prospects'),
			])
		;
	}`

In the view:
`{{ form_errors(form) }}
{{ form_start(form) }}

{{ form_widget(form) }}

<button type="submit" class="btn btn-primary">{{ button_text|trans }}</button>

{{ form_end(form) }}`

I also tried to add the constraint about extension in the buildForm function. Same result.

Thx in advance for your help!

Reply

Hmm, everything looks good... the only thing I can tell is that you didn't call {{ form_start(form) }} on your template, if that doesn't fix it, we are in troubles haha. Try clearing the cache, submit another csv file, double check your imports (you may have chosen a wrong one)

Cheers!

Reply

Hello!

The form_start call is in the template :) When I dump the information about the uploaded file (dd($uploadedfile)), it displayes:

UploadedFile^ {#64 ▼<br /> -test: false<br /> -originalName: "leads_solvary.csv"<br /> -mimeType: "text/csv"<br /> -error: 0<br /> path: "/private/var/folders/c_/t_wbmqbd3dz003yhxr8fgsqw0000gn/T"<br /> filename: "phpHshBYz"<br /> basename: "phpHshBYz"<br /> pathname: "/private/var/folders/c_/t_wbmqbd3dz003yhxr8fgsqw0000gn/T/phpHshBYz"<br /> extension: ""<br /> realPath: "/private/var/folders/c_/t_wbmqbd3dz003yhxr8fgsqw0000gn/T/phpHshBYz"<br /> aTime: 2019-07-18 16:10:23<br /> mTime: 2019-07-18 16:10:23<br /> cTime: 2019-07-18 16:10:23<br /> inode: 8844478793<br /> size: 1889<br /> perms: 0100600<br /> owner: 501<br /> group: 20<br /> type: "file"<br /> writable: true<br /> readable: true<br /> executable: false<br /> file: true<br /> dir: false<br /> link: false<br />}

As you can see, the mimeType is "text/csv". But the validation failed saying that I uploaded a "text/plain" file instead of a csv ...

By the way, in the mime types, I have added pdf ("application/pdf", "application/x-pdf") and this is working. So the problem is really with csv.

Any idea?

Reply

Hey be_tnt!

Nice just checking into this and dumping the UploadedFile object. When it comes to asking "What is the mime type of this file?" there are *two* places that information can come from. First, when the user submits the form, *their* browser sends information that says "this is text/csv". But, this can't be trusted... as a user could "spoof" this and send anything. So, second, what Symfony can do (and *does* do) is look at the uploaded file's contents and try to guess the mime type. I believe this what you're seeing: the user's browser is saying "Hey! This is text/csv", but then Symfony looks inside of the file and just sees "text/plain"... probably because CSV basically is a text/plain format (or very similar).

To solve this, I would just allow text/plain. If you really wanted to make sure that *only* CSV files (and not just random text files) were uploaded, you could add your own next layer of validation that tried to parse the file's contents as CSV and creates a validation if it fails (that would be a custom validator).

Let me know if this helps!

Cheers!

Reply

Thx for your answer. It helps a lot to understand the validation process. I will have to parse the csv file in my project so the latest validation step will be done there :)

Thx again!

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