javascriptpythonflaskbootstrap-toast

Flask's form validation error handling in Javascript by omclick event


So, there is a file uploading form.

forms.py

from flask_wtf import FlaskForm 

class Form(FlaskForm):
    file = FileField('File',
                     validators=[FileRequired(msg),
                                 FileAllowed(ext, msg),
                                 FileSize(max_, min_, msg)])    
    upload = SubmitField('Upload')

upload.html

<form method="POST" enctype="multipart/form-data">
  {{ form.hidden_tag() }}
  {{ form.file(class="form-control", id="file") }}
  <div id="error" data-error="{{ form.file.errors[0] }}"></div> <!-- * -->
  {{ form.upload(class="btn btn-outline-secondary", id="upload") }}
</form>
script.js

let fu = document.querySelector('#upload');

fu.addEventListener('click', (ev) => {
    let el = document.querySelector('#error');
    if (el.dataset.error) {
        // Show error message and do nothing else
        let toast = document.querySelector('.toast');
        document.querySelector('.toast-body').innerText = el.dataset.error;
        bootstrap.Toast.getOrCreateInstance(toast).show(); // initialize
    }
    // Let Flask do its job
});

Obviously, when I press upload button and JS function fires immediately. There is still no information about errors occured (if any), because validation on the server side hasn't happened yet.

Also I tried to solve problem with websockets:

Python

@app.route('/upload', methods=['GET', 'POST'])
def upload():
    form = Form()

    if request.method == 'POST':
        if not form.validate():
            socketio.emit('err', {'error': form.file.errors[0]})
    # ...

JS

const ws = io();
ws.on('err', (ev) => (
    // let error = ev.error;
    // Yhe same logic
));

But recieved Invalid frame header error on the client side.

Finally I got what I wanted. With a lot of code and repeating AJAX-requests calls. I'm sure there is much more elegant solutiom. Probably I miss something. Thanks.


Solution

  • One possibility is to create the toasts with Jinja and display them in a directly executed script. In this case you don't need Ajax or websockets. The entire page will reload and the toasts will be shown.

    class UploadForm(FlaskForm):
        file = FileField('File',
            validators=[
                FileRequired(msg),
                FileAllowed(ext, msg),  
                FileSize(max_, min_, msg)
            ]
        )
        submit = SubmitField('Upload')
    
    @app.route('/upload', methods=['GET', 'POST'])
    def upload():
        form = UploadForm()
        if form.validate_on_submit():
            # ...
        return render_template('upload.html', **locals())
    
    {% if form.errors -%}
    <div class="toast-container position-fixed bottom-0 end-0 p-3">
        {% for k,v in form.errors.items() -%}
        <div class="toast align-items-center" role="alert" aria-live="assertive" aria-atomic="true">
            <div class="d-flex">
                <div class="toast-body">
                    {{ v[0] }}
                </div>
                <button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
            </div>
        </div>
        {% endfor -%}
    </div>
    {% endif -%}
    
    (function() {
        const toastElList = document.querySelectorAll('.toast')
        toastElList.forEach(toastEl => {
            bootstrap.Toast.getOrCreateInstance(toastEl).show();
        });
    })();
    

    If you really want to use Ajax, you can also create the toasts via script and then display them, as the following example shows. The page is not completely reloaded, but only individual parts are created dynamically.

    @app.route('/')
    def index():
        form = UploadForm()
        return render_template('index.html', **locals())
    
    @app.post('/upload')
    def upload():
        form = UploadForm()
        if form.validate():
            # ...
            return jsonify(errors=[])
        return jsonify(errors=form.errors)
    
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Index</title>
        <link 
            href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" 
            rel="stylesheet" 
            integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" 
            crossorigin="anonymous">
    </head>
    <body>
        <main class="container my-4">
            <form name="upload-form" method="POST" enctype="multipart/form-data">
                <div class="mb-3">
                    {{ form.hidden_tag() }}
                    {{ form.file(class_='form-control') }}
                </div>
                <div class="mb-3 d-grid gap-2">
                    {{ form.submit(class_='btn btn-outline-secondary') }}
                </div>
            </form>
        </main>
    
        <div class="toast-container position-fixed bottom-0 end-0 p-3"></div>
    
        <script 
            src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" 
            integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" 
            crossorigin="anonymous"></script>
        <script>
            (function() {
                const formEl = document.querySelector('form[name="upload-form"]');
                formEl.addEventListener('submit', function(event) {
                    event.preventDefault();
                    fetch('/upload', {
                        method: 'POST', 
                        body: new FormData(this)
                    }).then(resp => resp.json())
                        .then(data => {
                            if (data && data.errors && Object.keys(data.errors).length) {
                                const toastsEl = document.querySelector('.toast-container');
                                toastsEl.innerHTML = Object.keys(data.errors).map(k => {
                                    return `
                                        <div class="toast align-items-center" role="alert" aria-live="assertive" aria-atomic="true">
                                            <div class="d-flex">
                                                <div class="toast-body">
                                                    ${ data.errors[k][0] }
                                                </div>
                                                <button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
                                            </div>
                                        </div>
                                    `;
                                }).join('');
                                toastsEl.querySelectorAll('.toast').forEach(toastEl => {
                                    bootstrap.Toast.getOrCreateInstance(toastEl).show();
                                });
                            } else {
                                this.reset();
                            }
                        })
                });
            })();
        </script>
    </body>
    </html>
    

    I don't think it's necessary to use websockets.