Importing Text From File Into a textarea HTML Form Field

Introduction

There is often a need to create a text field in a model that stores information you may have saved elsewhere as a plain text file such as configuration files or CSV values that you might want to render as an HTML table, but you only want the contents, not to upload the file to your web server.

The first part of this article takes you through the necessary front-end code to enable this from two different events: from a file input button change event, and from a (mouse) drop event.

In the second part, we'll implement this in a custom Wagtail FieldPanel that you can use with a standard Django textfield.

The next article will show you how to use the same front end process in a reusable StructBlock.

fa-regular fa-pen-to-square fa-xl Note
For clarity, the aim of this article is to augment a textarea form field with the ability to read plain text from a local file, not to create a file upload tool.

Reading a File With JavaScript

The FileReader Object

The JavaScript FileReader object provides a way to read the contents of a file asynchronously. To read a file, you first create a new instance of the FileReader object then attach an event listener to the object's load event, which fires when the file is successfully loaded.

Inside the event listener, you can access the file contents using the result property of the FileReader object. The reading process by calling the readAsText(), readAsDataURL() or readAsArrayBuffer() method, passing in the file you want to read as the parameter. Since this example is dealing with plain text files, we'll be using readAsText().

The FileReader object asynchronously reads the file, and when completed, triggers the load event, allowing you to access and work with the file's contents. Here, we'll be taking that result and setting it to the textarea value attribute.

Since the process for reading a file from either an input element or a drag/drop event, we'll create a single method readFile() and pass in the filename from either event.

Reading a file from an input element

Using <input type="file"/> gives us the familiar 'Choose File' button. We'll add an listener to trigger on the change event and pass the returned file name into the readFile() method. Once that has passed, in this case, it's important to clear the value attribute of the input control. The reason for this is that if the editor notices a mistake in the file contents, updates the file and reselects the file from the input control, the change event will not be triggered again if that value has been left after the last event. Clearing the value ensures the change event is triggered each time. We'll also blur() the control to take the focus off the file chooser button after the read operation.

Using preventDefault to perform custom actions

By calling event.preventDefault(), the default behaviour of the change event is suppressed, preventing the file selection dialog from opening.

The reason preventDefault() is used in this context is to handle the file selection process manually using the readFile() function and custom logic. By preventing the default behaviour, you can take control of how the selected file is processed, read, and passed to the readFile() function.

Reading a file from a drag/drop event

To enable this on the textarea control, we add event listeners for the dragover and drop events.

The dragover event is just to provide some visual feedback that the control is available for drag/drop by setting event.dataTransfer.dropEffect to copy.

The drop event provides us with a list of files via the dataTransfers.files attribute of the event. For this purpose, we will only take the first file in the list in the case that multiple files have been selected for one drop.

Using stopPropagation to prevent event bubbling.

The stopPropagation() method is used in JavaScript to prevent the propagation of events from reaching further elements in the DOM tree. When applied to the dragover and drop events, it allows you to control the event flow during drag and drop interactions. By calling stopPropagation() on these events, you prevent them from triggering any event listeners on parent or ancestor elements. This ensures that only the intended element receives and handles the drag and drop events, avoiding unwanted side effects or conflicting behaviours caused by event bubbling.

Initialising the Form Controls

Because this text field control could be used multiple times on one form, we need to make sure there are unique events for each instance. We'll create a JavaScript that receives an ID and initialises the necessary elements. We'll later add this id to the panel wrapper and instantiate the class with this ID:

class ImportTextFieldPanel {
    constructor(id) {
        this.wrapper = document.querySelector(`[data-field-wrapper="${id}"]`);
        this.fileInput = this.wrapper.querySelector('input');
        this.textArea = this.wrapper.querySelector('textarea');

        this.textInitialHeight = this.textArea.style.height;
        if (this.textArea.style.maxHeight === '') {
            this.textArea.style.maxHeight = '30em';
        }
        this.textArea.style.overflowY = 'auto';

        this.fileInput.addEventListener('change', this.handleFileInputChange.bind(this));
        
        this.textArea.parentElement.addEventListener('dragover', this.handleDragOver.bind(this));
        this.textArea.parentElement.addEventListener('drop', this.handleDrop.bind(this));
    }

    readFile(source, target) {
        const reader = new FileReader();
        reader.addEventListener('load', (event) => {
            target.value = event.target.result;
            target.style.height = this.textInitialHeight;
            target.style.height = target.scrollHeight 
                + parseFloat(getComputedStyle(target).paddingTop) 
                + parseFloat(getComputedStyle(target).paddingBottom) + 'px';
        });
        reader.readAsText(source);
    }

    handleFileInputChange(event) {
        event.preventDefault();
        const input = this.fileInput.files[0];
        this.readFile(input, this.textArea);
        this.fileInput.value = '';
        this.fileInput.blur();
    }

    handleDragOver(event) {
        event.stopPropagation();
        event.preventDefault();
        event.dataTransfer.dropEffect = 'copy';
    }

    handleDrop(event) {
        event.stopPropagation();
        event.preventDefault();
        const input = event.dataTransfer.files[0];
        this.readFile(input, this.textArea);
    }
}

As well as the event listeners described previously, the function performs the following tasks:

  1. It receives an ID for the field panel wrapper element's data-field-wrapper attribute and retrieves that node object from the DOM. The textarea and file input child elements are stored as textFieldand fileInput. Note, the data-field-wrapper attribute is a Wagtail feature. If you're using another platform, just wrap the text and input elements in a container use an appropriate attribute to return that container's node in the DOM. Adust the class contruct method as needed.
  2. textInitialHeight stores the initial height of the textField element, used in dynamically setting the textarea height after a read file event..
  3. It checks for a maxHeight attribute on the textField element. If there is none, it sets the maxHeight property to '30em'. This ensures that the textField element has a maximum height to prevent the control from growing oversized after reading a large file.
  4. It sets the overflowY property of the textField element's style to auto. This enables vertical scrolling when the content exceeds the maxHeight of the textField element.
  5. It defines a function called readFile that takes two parameters: source and target. This function is responsible for reading the contents of a file selected by the user and displaying the content in the target element. It uses the FileReader API to read the file contents asynchronously.
    1. The readFile function creates a new FileReader object, attaches an event listener to it and sets up a callback function to handle the load event. When the file is successfully loaded, the callback function is executed.
    2. In the callback function, it assigns the loaded file's contents to the value property of the target element (in this case, the textarea element). The height of the target element is adjusted dynamically to fit the content properly based on the scrollHeight of the target element plus the top and bottom padding.
    3. Finally, it calls the readAsText method of the FileReader object, passing in the source file object. This initiates the process of reading the file as text.

Example textarea with File Reading Enabled

Create a simple form with the following HTML (assumes you have the ImportTextFieldPanel JavaScript class loaded and Bootstrap enabled):

<style>
    .textarea-fileinput, .textarea-fileinput::file-selector-button {
        width: 10em;
    }
</style>
<div data-field-wrapper="some_text_area">
    <form class="bg-light p-3 rounded mx-md-5">
        <textarea class="overflow-auto form-control mb-2 w-100"></textarea>
        <input type="file" class="form-control textarea-fileinput">
    </form>
</div>
<script>
    new ImportTextFieldPanel("some_text_area");
</script>

This gives you a form similar to the following.

This is a working form, feel free try the 'Choose File' button and drag/drop with a test text file to see it in action.

Note

I'm setting the input element and the file selector button to the same width with

.textarea-fileinput, .textarea-fileinput::file-selector-button { width: 10em; }

I'm doing this in order to hide selected file text that the input element would normally display. Because we set the input value to to empty after each file read operation, this will always display "No file selected" and would possibly be confusing for the editor.

An alternative to this is to set a unique id on the <input> element and hide it, create a <label> with the for attribute equal to the uid of the <input> and style it as a button.

<form class="bg-light p-3 rounded mx-md-5">
    <textarea class="overflow-auto form-control mb-2 w-100"></textarea>
    <input type="file" id={{ id }} hidden="" class="form-control textarea-fileinput">
    <label for={{ id }} class="btn btn-primary">Choose File</label>
</form>
Example file input with hidden field and label style as button.

Creating a Custom Wagtail Field Panel

Note

The example below is intended as a demonstration of the technique to create a custom FieldPanel with JavaScript interaction rather than a solution to the case of adding file reading ability to a text area. A better solution for that case using a custom widget is given in the this article.

I'll assume you have a working knowledge of Wagtail's FieldPanel for this, it's beyond the scope of this article to cover that in depth.

If not, in a nutshell, this is the class that provides the relevant form control for most field types in Wagtail's CMS. You can easily sublass this and add keyword attributes via the clone_kwargs() method which will then be available to the BoundPanel. BoundPanel is, as the name suggests, where the form control is bound to the named field name in the model. The render_html() method is where the magic happens to render the control on the editor form.

You can find more complete information on Wagtail's Panel API documentation.

fa-solid fa-triangle-exclamation fa-xl Important
This article assumes you are using Wagtail 3.x or higher. The FieldPanel is not present in earlier versions.

Adding a file type filter

The file input element can take an accept attribute to limit the types of files the editor can choose from the pop out file dialog. See the Mozilla Developer docs for a full explanation of these.

We'll add this an option to the FieldPanel kwargs and check for this when rendering the form control HTML.

Note, this only affects the file input control. It does not prevent files of other types being drag/dropped onto the text area.

ImportTextFieldPanel

from bs4 import BeautifulSoup
from django.core.exceptions import ImproperlyConfigured
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from wagtail.admin.panels import FieldPanel


class ImportTextFieldPanel(FieldPanel):
    """
    TextArea form field with option to import from file or drag/drop.
    file_type_filter: any valid accept string
    https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept
    """

    def __init__(self, field_name, file_type_filter=None, *args, **kwargs):
        self.file_type_filter = file_type_filter
        super().__init__(field_name, *args, **kwargs)

    def clone_kwargs(self):
        kwargs = super().clone_kwargs()
        kwargs.update(
            file_type_filter=self.file_type_filter,
        )
        return kwargs

    class BoundPanel(FieldPanel.BoundPanel):
        def __init__(self, **kwargs):
            super().__init__(**kwargs)
            self.accepts = (
                f'accept="{self.panel.file_type_filter}" '
                if self.panel.file_type_filter
                else ""
            )

        msg = {
            "file_label": _("Use 'Choose File' or drag/drop to import data from file."),
        }

        @mark_safe
        def render_html(self, parent_context=None):
            html = super().render_html(parent_context)
            soup = BeautifulSoup(html, "html.parser")
            text_area = soup.find("textarea")
            if not text_area:
                raise ImproperlyConfigured(
                    _("ImportTextFieldPanel should only be used with TextFields")
                )
            text_area["class"] = text_area.get("class", []) + ["import_text_area"]
            wrapper = soup.find(attrs={"data-field-wrapper": True})
            wrapper["data-field-wrapper"] = self.id_for_label()
            wrapper.append(self.import_text_field_button())
            wrapper.append(self.initialise_panel())
            return str(soup)

        def import_text_field_button(self):
            return BeautifulSoup(
                """
                <div class="textarea-fileinput-container">
                    <input type="file" class="textarea-fileinput"
                     """ + self.accepts + """ 
                    />
                    <span class="help">
                    """ + self.msg["file_label"] + """
                    </span> 
                </div>
                """,
                "html.parser",
            )

        def initialise_panel(self):
            return BeautifulSoup(
                f'<script>new ImportTextFieldPanel("{self.id_for_label()}")</script>',
                "html.parser",
            )
  • file_type_filter is added to the FieldPanel kwargs.
  • The BoundPanel initialistion checks for the presence of this filter and assigns it to the accepts variable.
  • render_html() calls the inherited BoundPanel.render_html() method, stores the result and passes this into a BeautifulSoup object.
  • If no textarea element is found in the passed HTML, an error is raised using Django's ImproperlyConfigured method, otherwise we add an additional CSS class to the textarea to allow some additional formatting.
  • The rendered panel's wrapper div element gets its "data-field-wrapper" attribute value set to the value returned by the panel's id_for_label method.
  • import_text_field_button() appends the HTML for the file input element to the wrapper element. There is also some basic help text to assist the editor. The div container handles how to wrap these elements on narrow screen sizes (see CSS below).
  • initialise_panel() appends to the wrapper a script element to instantiate the ImportTextFieldPanel JavaScript class using the panel's id_for_label value.
  • Finally, the BeautifulSoup object is converted to a string and returned to render the complete panel.

Adding custom CSS to the panel

We need to add come CSS to control the layout and behaviour of the panel.

Aside from some basic styling, we set:

  • The file input and the file selector button to the same width (for reasons explained above).
  • The file input container properties to display flex and set the flex-wrap to handle positioning the elements on small screen sizes.
  • Set the text area maxHeight and vertical scroll properties.
  • Note we need to use !important on those attributes that Wagtail's built-in CSS would normally have precedence over.
.textarea-fileinput {
    width: 10em !important;
    border: 0 !important;
    padding: 0 !important;
    margin-right: 1rem;
}
.textarea-fileinput::file-selector-button {
    width: 10em;
    padding: 0.3rem;
    border: 0;
    border-radius: 5px;
}
.textarea-fileinput-container {
    display: flex;
    flex-wrap: wrap;
    padding-top: 1em;
}
.import_text_area {
    max-height: 30em !important;
    overflow-y: auto !important;
    width: 100%;
}
fa-solid fa-triangle-exclamation fa-xl Important:
Depending on Wagtail version and/or any custom admin CSS you have in place, you may need to adjust this CSS to meet your needs.

Registering the custom JavaScript and CSS in the Admin interface

We'll make use of two hooks to do this: insert_global_admin_js and insert_global_admin_css

In wagtail_hooks.py:

from django.templatetags.static import static
from django.utils.safestring import mark_safe
from wagtail import hooks

@hooks.register('insert_global_admin_js')
def register_admin_js():
    import_text_field_panel_js = static('js/import_text_field_panel.js')
    return mark_safe(
        f'<script src="{import_text_field_panel_js}"></script>'
    )

@hooks.register('insert_global_admin_css')
def register_admin_css():
    import_text_field = static('css/import_text_field.css')
    return mark_safe(
        f'<link rel="stylesheet" href="{import_text_field}">'
    )

Using the ImportTextFieldPanel

Use this in any Page or Django model where you have a TextField.

In the content_panels declaration, use ImportTextFieldPanel in place of FieldPanel and, optionally, specify a file type filter.

For example, if you had a Page model with an air_quality field used to store data from comma separated value files (.csv), you might use the following:

from wagtail.models import Page
from django.db import models

from core.panels import ImportTextFieldPanel

class SomePage(Page):
    air_quality = models.TextField(verbose_name="Air Quality")

    content_panels = Page.content_panels + [
        ImportTextFieldPanel('air_quality', file_type_filter=".csv"),
    ]

The rendered panel after a completed file reading operation would be similar to the following:

ImportTextFieldPanel custom wagtail fieldpanel importing file text to a text area with javascript
The rendered panel after importing CSV data

Conclusion

Importing text files into HTML form fields using the JavaScript FileReader object offers a powerful solution for incorporating external data seamlessly. Use of this technique facilitates efficient data transfer, enhances data input capabilities and improves user experience.

In this article, we covered the following points:

  • How to import text from file on your web form without the need to upload files to your web server.
  • Using the file type <input> element, including customising its appearance and adding a file type filter with the accept attribute.
  • Using the change event on that element to perform custom behaviour by sending the chosen file to a FileReader object to read the file contents and pass the result to another element.
  • Adding drag/drop capability to a standard <textarea> element by adding event listeners for the dragover and drop events and adding custom actions to the drop event to read the file contents into the textarea.

In addition, we covered creating a custom FieldPanel to use this technique in Wagtail's CMS including using hooks to register custom JavaScript and CSS.

In the next article, I'll cover how to use this technique in a reusable StructBlock for use in Wagtail's StreamField editor interface.


  Please feel free to leave any questions or comments below, or send me a message here