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
.
Note
For clarity, the aim of this article is to augment atextarea
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:
- It receives an ID for the field panel wrapper element's
data-field-wrapper
attribute and retrieves that node object from the DOM. Thetextarea
and fileinput
child elements are stored astextField
andfileInput
. Note, thedata-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. textInitialHeight
stores the initial height of thetextField
element, used in dynamically setting thetextarea
height after a read file event..- It checks for a
maxHeight
attribute on thetextField
element. If there is none, it sets themaxHeight
property to '30em'. This ensures that thetextField
element has a maximum height to prevent the control from growing oversized after reading a large file. - It sets the
overflowY
property of thetextField
element's style toauto
. This enables vertical scrolling when the content exceeds themaxHeight
of thetextField
element. - It defines a function called
readFile
that takes two parameters:source
andtarget
. This function is responsible for reading the contents of a file selected by the user and displaying the content in thetarget
element. It uses theFileReader
API to read the file contents asynchronously.- The
readFile
function creates a newFileReader
object, attaches an event listener to it and sets up a callback function to handle theload
event. When the file is successfully loaded, the callback function is executed. - In the callback function, it assigns the loaded file's contents to the
value
property of thetarget
element (in this case, thetextarea
element). The height of thetarget
element is adjusted dynamically to fit the content properly based on thescrollHeight
of thetarget
element plus the top and bottom padding. - Finally, it calls the
readAsText
method of theFileReader
object, passing in thesource
file object. This initiates the process of reading the file as text.
- The
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.
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>
Creating a Custom Wagtail Field Panel
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.
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 theFieldPanel
kwargs
.- The
BoundPanel
initialistion checks for the presence of this filter and assigns it to theaccepts
variable. render_html()
calls the inheritedBoundPanel.render_html()
method, stores the result and passes this into aBeautifulSoup
object.- If no
textarea
element is found in the passed HTML, an error is raised using Django'sImproperlyConfigured
method, otherwise we add an additional CSS class to thetextarea
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'sid_for_labe
l method. import_text_field_button()
appends the HTML for the fileinput
element to the wrapper element. There is also some basic help text to assist the editor. Thediv
container handles how to wrap these elements on narrow screen sizes (see CSS below).initialise_panel()
appends to the wrapper ascript
element to instantiate theImportTextFieldPanel
JavaScript class using the panel'sid_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 theflex-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%;
}
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:
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 theaccept
attribute. - Using the
change
event on that element to perform custom behaviour by sending the chosen file to aFileReader
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 thedragover
anddrop
events and adding custom actions to thedrop
event to read the file contents into thetextarea
.
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.