Search code examples
javascripthtmlflaskdrag-and-dropjinja2

Changing database via Drag and Drop


I am trying to implement a Kanban application (To-Do application) in Flask and HTML. I can add tasks, they are displayed in the respective column (To-Do, In Progress, Done) and I can change the status of the tasks, which moves them in the respective column. Now I am trying to make the tasks draggable and change their status according to the respective column they are dropped in, that's when I had to start using JavaScript to implement the event handlers for the Drag and Drop API of HTML.

In the Jinja Template of the application I have my event listeners defined for the Drag and Drop API:

<script>
  function allowDrop(ev) {
    ev.preventDefault();
  }

  function dragEnter(ev) {
    ev.preventDefault();
    $(this).addClass('zoomed');
  }
  
  function drag(ev) {
    ev.dataTransfer.setData("task_id", ev.target.id);
  }
  
  function drop(ev) {
    ev.preventDefault();
    var task_id = ev.dataTransfer.getData("task_id");
    var targe_state = ev.target.id;
    {{ dropped(project_id=project.id, task_id=task_id, target_state=target_state) }}
  }
</script>

The actual logic happens in the call to dropped which is a python function passed to the Jinja template:

def dropped(project_id, task_id, target_state):
    project_id = int(project_id)
    task_id = int(task_id)
    if Project.query.get(project_id) is None:
        return render_template("404.html"), 404
    try:
        session = sessions_by_project[project_id]
    except KeyError:
        return render_template("404.html"), 404
    task = session.query(Task).get(int(task_id))
    if task is None:
        return render_template("404.html"), 404
    task.change_status(str2status(target_state))
    session.commit()
    print(f"Dropped called with target ID: {task_id}, state: {target_state}")
    return redirect(url_for("main.kanban", project_id=project_id))

@main.route("/<int:project_id>/kanban", methods=["GET", "POST"])
def kanban(project_id):
    # ...more code ommited...
    return render_template(
        "kanban.html", tasks_by_status=tasks_by_status, project=project, dropped=dropped
    )

The part of the Jinja template were the event handlers are set is (there are two more ordered lists for "In Progress" and "Done" which are basically the same so I omitted them):

    <ol class="kanban To-do" id="todo" ondrop="drop(event)" ondragover="allowDrop(event)" ondragenter="dragEnter(event)">
        <h2> To-Do </h2>
        {% for (depth, pending) in tasks_by_status[1]%}
            <li class="dd-item indent{{depth}}" id="{{pending.id}}" draggable="true" ondragstart="drag(event)">
                <h3 class="title dd-handle"><button name="task" value={{pending.id}}>{{pending.title}}</button></h3>
                <div class="text" contenteditable="true">{{pending.description}}</div>
            </li>
        {% endfor%}
    </ol>

So from what I see during testing and trying around:

  • I can drag the list-items marked with draggable=true
  • Nothing happens when I move the draggable items over the ordered list elements, although drageEnter should be called and make the element appear slightly bigger
  • Also nothing happens when I drop something over the ordered list elements
  • When I use hard coded values for calling the dropped(1,1,"pending") I can see the function print to the console whenever the page is loaded but not when the drop event is fired.
  • After more investigation it seems like the dropzone event handlers (ondragenter, ondragover, ondragleave, ondrop) are not called when the events should be fired, while the ondragstart and ondragend event handlers do work => could it be that ordered lists can't be used as dropzones?

I also tried not to pass the dropped function to the template but rather add it to the flask context processor which also didn't help. Hope anyone knows what's going on and where I made a mistake - I hope it is not too silly. Much appreciated, thanks!


Solution

  • Like Qori said, the function passed to the jinja template is called whenever the page is loaded and the result is put into the template to produce the displayed HTML page. So in order to call the function with the right parameters on the backend site, I needed to send the data via the fetch API...:

      const kanbanUrl = "{{url_for('main.kanban', project_id=project.id)}}";
      async function drop(ev, el) {
        ev.preventDefault();
        
        var taskId = ev.dataTransfer.getData("task_id");
        var targetState = el.id;
    
        document.getElementById("main").style.opacity = "1.0";
    
        const response = await fetch(kanbanUrl, {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          redirect: "follow",
          body: JSON.stringify({task_id: taskId, target_state: targetState}),
        }).then(() => {window.location.reload();});
        return response.json();
      }
    

    ...and then process it:

    @main.route("/<int:project_id>/kanban", methods=["GET", "POST"])
    def kanban(project_id):
        project = Project.query.get(project_id)
        if project is None:
            return render_template("404.html"), 404
        try:
            session = sessions_by_project[project_id]
        except KeyError:
            return render_template("404.html"), 404
    
        tasks_by_status = defaultdict(list)
        for depth, task in walk_list(session.query(Task).all()):
            tasks_by_status[task.status.value].append((depth, task))
    
        if request.method == "POST" and "task" in request.form:
            return redirect(
                url_for("main.task", project_id=project_id, id=int(request.form["task"]))
            )
    
        if (
            request.method == "POST"
            and "task_id" in request.json
            and "target_state" in request.json
        ):
            task_id = int(request.json["task_id"])
            target_state = request.json["target_state"]
            
            print(f"Changig state of task: {task_id} to state: {target_state}")
            task = session.query(Task).get(task_id)
            if task is None:
                return render_template("404.html"), 404
            task.change_status(str2status(target_state))
            session.commit()
            
            next = request.args.get('next')
            if next is None or not next.startswith('/'):
                next = url_for('main.kanban', project_id=project_id)
            return redirect(next)
    
        return render_template(
            "kanban.html", tasks_by_status=tasks_by_status, project=project
        )
    

    In order to see the changes to the database, the page has to be reloaded, this happens in the callback passed to the fetch API via then.

    Furthermore, in order to execute the callback on the right element, in my case the ordered list element, and not on any of the child elements, I needed to pass the element to the callback via this:

        <ol class="kanban To-do" id="todo" ondrop="drop(event, this)" ondragover="dragOver(event, this)" ondragenter="dragEnter(event, this)" ondragleave="dragLeave(event, this)">
            <h2> To-Do </h2>
            {% for (depth, pending) in tasks_by_status[1]%}
                <li class="dd-item indent{{depth}}" id="{{pending.id}}" draggable="true" ondragstart="dragStart(event)" ondragend="dragEnd(event)">
                    <h3 class="title dd-handle"><button name="task" value={{pending.id}}>{{pending.title}}</button></h3>
                    <div class="text" contenteditable="true">{{pending.description}}</div>
                </li>
            {% endfor%}
        </ol>
    

    Another problem was, that ondragleave and ondragenter also where called when hovering over child elements. I followed the advice here and implented a counter for the ondragenter and ondragleave callbacks:

      var counter = 0;
    
      function dragEnter(ev, el) {
        ev.preventDefault();
        ev.stopPropagation();
        counter++;
        el.style.opacity = "1.0";
        el.style.zoom = "1.1";
      }
    
      function dragLeave(ev, el) {
        ev.preventDefault();
        ev.stopPropagation();
        counter--;
        if(counter == 0)
        {
          el.style.opacity = "0.5";
          el.style.zoom = "1.0";
        }
      }
    

    Now everything works as desired: I can drag list items on one of the ordered lists and get the desired highlighting effect, without flickering when hovering child elements. Furthermore the right IDs (task_id, target_state) are sent to the backend via the fetch API, the changes to the database are made, the page reloads and I can see the changes.