2022-06-08T19:02:49Z

Beautiful Flask Tables, Part 2

It's been almost a year since I published my Beautiful Interactive Tables for your Flask Templates article, in which I showed a few example tables, rendered from a standard Flask and Jinja template, with some JavaScript help.

I have received a good amount of feedback on my proposed solutions. In this article I'm going to refresh my table examples to address the two most frequently requested improvements.

Goodbye DataTables, Hello Grid.js

To support the interactive aspects of the tables, in my first article I have used the DataTables JavaScript library. This library is designed as a plugin for jQuery, which means that the library does not work without jQuery being included in your page.

Up until a few years ago nobody considered this to be a problem, because jQuery was a must-have library that greatly reduced incompatibilities between web browsers. But these incompatibilities are a thing of the past now, so new projects are unlikely to need jQuery as a dependency, and lots of old projects have refactored it out.

At the time I worked on my first article, I did not find any viable alternatives to DataTables, so I could not create a solution without jQuery. But now, I have found a new table library that does not have any dependencies: Grid.js!

Grid.js does not have any external dependencies, just its own .css and .js files:

<link href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css" rel="stylesheet" />
<script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script>

I have created a new GitHub repository with several table examples, all based on Grid.js. To try out these examples, clone the repository, create a virtual environment, and install the requirements as usual.

The next sections provide a short overview of each solution. In all cases, you can find the complete working code in the source repository.

Basic table

When using Grid.js, the only HTML markup that you need to define is a parent <div> element for the table:

<div id="table"></div>

Then you can initialize a simple table with this code in a <script> block:

      new gridjs.Grid({
        columns: [
          { id: 'name', name: 'Name' },
          { id: 'age', name: 'Age' },
          { id: 'address', name: 'Address' },
          { id: 'phone', name: 'Phone Number' },
          { id: 'email', name: 'Email' },
        ],
        data: [
          {% for user in users %}
            {
              name: '{{ user.name }}',
              age: {{ user.age }},
              address: '{{ user.address }}',
              phone: '{{ user.phone }}',
              email: '{{ user.email }}'
            },
          {% endfor %}
        ],
        search: true,
        sort: true,
        pagination: true,
      }).render(document.getElementById('table'));

The Grid object is initialized with the columns and data options. Each column is configured with an id and a name, which is the text that appears in the column header. The data object is an array where each row is an object, in which the value for each column is provided under a key matching the column id value. I've used Jinja templating expressions to generate this array in a way that is valid JavaScript code that the browser can parse.

There are several other options that can be provided in addition to the two mentioned above. A few that I've found useful are:

  • search: true to add a search box.
  • sort: true to add sort buttons to each column header.
  • pagination: true to add pagination buttons at the bottom of the table.

For additional customization, these three options can be set to an object with more options instead of true. The documentation and examples should give you a good idea of what options are available.

To try out the basic table example, begin by creating a database with some randomly generated data:

python create_fake_users.py 100

Then start the example with this command:

python basic_table.py

You can visit http://localhost:5000 in your web browser to see the application in action.

Ajax Table

The basic table example is nice and simple, but is not very efficient when the number of rows is very large, because the Jinja template needs to render all those rows to the page, and while it does that the browser has to wait.

An improvement is to move the data to a separate endpoint that is invoked after the page is displayed with an empty table. To do this, the data option is replaced with the server option:

      new gridjs.Grid({
        columns: [
          { id: 'name', name: 'Name' },
          { id: 'age', name: 'Age' },
          { id: 'address', name: 'Address' },
          { id: 'phone', name: 'Phone Number' },
          { id: 'email', name: 'Email' },
        ],
        server: {
          url: '/api/data',
          then: results => results.data,
        },
        search: true,
        sort: true,
        pagination: true,
      }).render(document.getElementById('table'));

When using server, Grid.js will initialize an empty table, and then send a request to the endpoint provided in the server.url option. When a response is received, it will call the function provided in server.then, passing the received payload. The function must do any necessary processing and return an array with the actual data to populate the table.

To run the Ajax table example, first make sure you have created a database with some fake user information, as shown in the previous section. Then run the following command to start the example:

python ajax_table.py

The application runs on http://localhost:5000, same as the previous one.

Server-Driven Table

The Ajax table example is a bit more efficient when compared to the basic table, but it still has some performance issues with large datasets, because the Grid.js table makes a single request that returns all the rows.

The server-driven table is the most efficient solution, as it requests each page of rows when it is needed. Unfortunately this requires a much more complex solution, both in the client and the server. The searching, sorting and pagination operations were handled transparently by Grid.js in the previous two examples, but this is only possible when the table has the entire dataset in memory. When the client can only see the handful of rows that are displayed, all the processing needs to be offloaded to the server.

The updated client needs new logic to generate request URLs that include all the details, so that the server can retrieve the correct page of rows. And the server endpoint that returns rows needs to be expanded to perform searching, sorting and pagination.

The client code is shown below.

      const updateUrl = (prev, query) => {
        return prev + (prev.indexOf('?') >= 0 ? '&' : '?') + new URLSearchParams(query).toString();
      };

      new gridjs.Grid({
        columns: [
          { id: 'name', name: 'Name' },
          { id: 'age', name: 'Age' },
          { id: 'address', name: 'Address' },
          { id: 'phone', name: 'Phone Number' },
          { id: 'email', name: 'Email' },
        ],
        server: {
          url: '/api/data',
          then: results => results.data,
          total: results => results.total,
        },
        search: {
          enabled: true,
          server: {
            url: (prev, search) => {
              return updateUrl(prev, {search});
            },
          },
        },
        sort: {
          enabled: true,
          multiColumn: true,
          server: {
            url: (prev, columns) => {
              const columnIds = ['name', 'age', 'address', 'phone', 'email'];
              const sort = columns.map(col => (col.direction === 1 ? '+' : '-') + columnIds[col.index]);
              return updateUrl(prev, {sort});
            },
          },
        },
        pagination: {
          enabled: true,
          server: {
            url: (prev, page, limit) => {
              return updateUrl(prev, {start: page * limit, length: limit});
            },
          },
        },
      }).render(document.getElementById('table'));

This version also uses a server section, but the search, sort and pagination sections are expanded into objects, all having their own server sub-section. In this mode, Grid.js will build the URL that requests data by calling the server.url functions in the three sections. This URL is expected to return only the rows that need to be shown, considering the current settings for search, sort and pagination.

I'm not going to show the server-side code here, but if you are interested, you can see the /api/data endpoint in server_table.py in the source code repository.

To run the server-driven table example, use the following command:

python server_table.py

Then visit http://localhost:5000 on your browser.

Editable Cells

The last table example shows how to make some of the cells editable, which is the second frequently asked extensions to my previous table examples. Unfortunately editing is not a feature of Grid.js, so I have implement this myself, using the contentEditable attribute that all modern browsers support.

The JavaScript logic for this version is the longest, but a good portion of this code is the same as in the server-driven table. You can see the complete client-side code below.

      const tableDiv = document.getElementById('table');

      const updateUrl = (prev, query) => {
        return prev + (prev.indexOf('?') >= 0 ? '&' : '?') + new URLSearchParams(query).toString();
      };

      const editableCellAttributes = (data, row, col) => {
          if (row) {
            return {contentEditable: 'true', 'data-element-id': row.cells[0].data};
          }
          else {
            return {};
          }
      };

      new gridjs.Grid({
        columns: [
          { id: 'id', 'hidden': true },
          { id: 'name', name: 'Name', 'attributes': editableCellAttributes },
          { id: 'age', name: 'Age' },
          { id: 'address', name: 'Address', sort: false, 'attributes': editableCellAttributes },
          { id: 'phone', name: 'Phone Number', sort: false },
          { id: 'email', name: 'Email' },
        ],
        server: {
          url: '/api/data',
          then: results => results.data,
          total: results => results.total,
        },
        search: {
          enabled: true,
          server: {
            url: (prev, search) => {
              return updateUrl(prev, {search});
            },
          },
        },
        sort: {
          enabled: true,
          multiColumn: true,
          server: {
            url: (prev, columns) => {
              const columnIds = ['id', 'name', 'age', 'address', 'phone', 'email'];
              const sort = columns.map(col => (col.direction === 1 ? '+' : '-') + columnIds[col.index]);
              return updateUrl(prev, {sort});
            },
          },
        },
        pagination: {
          enabled: true,
          server: {
            url: (prev, page, limit) => {
              return updateUrl(prev, {start: page * limit, length: limit});
            },
          },
        },
      }).render(tableDiv);

      let savedValue;

      tableDiv.addEventListener('focusin', ev => {
        if (ev.target.tagName === 'TD') {
          savedValue = ev.target.textContent;
        }
      });

      tableDiv.addEventListener('focusout', ev => {
        if (ev.target.tagName === 'TD') {
          if (savedValue !== ev.target.textContent) {
            fetch('/api/data', {
              method: 'POST',
              headers: {'Content-Type': 'application/json'},
              body: JSON.stringify({
                id: ev.target.dataset.elementId,
                [ev.target.dataset.columnId]: ev.target.textContent
              }),
            });
          }
          savedValue = undefined;
        }
      });

      tableDiv.addEventListener('keydown', ev => {
        if (ev.target.tagName === 'TD') {
          if (ev.key === 'Escape') {
            ev.target.textContent = savedValue;
            ev.target.blur();
          }
          else if (ev.key === 'Enter') {
            ev.preventDefault();
            ev.target.blur();
          }
        }
      });

My solution adds event listeners for the focusin, focusout and keydown events. In focusin, the current contents of the cell are saved, in case the user later cancels the edit. In focusout, the modified cell contents are sent to the back end to be saved. The keydown event listens for the Escape and Enter keys, which cancel or save the edits respectively.

To run the final example with editable cells, use the following command:

python editable_table.py

Then visit http://localhost:5000 on your browser. In this example, only the cells in the Name and Address columns are editable.

Conclusion

If you are interested in implementing one of these solutions, I recommend that you clone the GitHub repository and study the examples carefully to understand the details of my implementation.

I'll look forward to hear new feedback. Maybe in another year I'll do yet another refresh to keep perfecting this project.

16 comments

  • #1 Gitau Harrison said 2022-06-24T22:51:02Z

    Thank you very much for yet another informative article. I am implementing grid.js in a flask app but it seems the HTML element is not being identified. A have a sample base template from which the style and js links from grid.js are added, and individual scripts added to relevant templates.

    base.html

    {% extends 'bootstrap/base.html' %}
    
    {% block styles %}
        {{ super() }}
        <link href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css" rel="stylesheet" />
    {% endblock %}
    
    # there is a contents block here too to help display the template
    
    {% block scripts %}
        {{  super() }}
        <script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script>
    
        {% block gridjs_scripts %}{% endblock %}
    {% endblock %}
    

    A basic_table template has a div with an id and the script is added accordingly.

    {% block app_context %}
        <div class="row">
            <div class="col-md-12">
                <h1>{{ title }}</h1>
                <div id="table"></div>
            </div>
        </div>
    {% endblock %}
    

    This template is rendered by:

    @app.route('/basic-table')
    def basic_table():
        create_fake_users(50)
        users = User.query.all()
        return render_template('basic-table.html', users=users, title='Basic Table')
    

    Using the raw table element, I am able to display all the contents found in the database, but not with grid.js. What could I be missing? Both CSS and JS are found in the base template and basic_table script added here.

  • #2 Miguel Grinberg said 2022-06-25T11:04:18Z

    @Gitau: Where is your Grid object? That's what gets the table created, see above in the article how it is used.

  • #3 Gitau Harrison said 2022-06-25T14:39:18Z

    I have the basic template set out below with the Grid object

    {% extends 'base.html' %}
    
    {% block app_context %}
        <div class="row">
            <div class="col-md-12">
                <h1>{{ title }}</h1>
                <div id="table"></div>
            </div>
        </div>
    {% endblock %}
    
    {% block gridjs_scripts %}
        <script>
            new gridjs.Grid({
                columns: [
                    {id: 'username', name: 'Username'},
                    {id: 'age', name: 'Age'},
                    {id: 'email', name: 'Email'},
                    {id: 'phone', name: 'Phone Number', sort: false},
                    {id: 'address', name: 'Address', sort: false}
                ],
                data: [
                    {% for user in users %}
                        {
                            username: '{{ user.username }}',
                            age: '{{ user.age }}',
                            email: '{{ user.email }}',
                            phone: '{{ user.phone }}',
                            address: '{{ user.address }}'
                        },
                    {% endfor %}
                ],
                search: true;
                sort: true,
                pagination: true;
            }).render(document.getElementById('table'));
        </script>
    {% endblock %}
    

    With the endpoint containing users as seen in the previous message.

  • #4 Miguel Grinberg said 2022-06-25T21:52:42Z

    @Gitau: I don't know what's wrong, I can't really figure it out by looking at the partial bits of templates that you are showing me. But this problem is unrelated to Grid.js, it is an issue with how you have organized your template hierarchy. I recommend that you start from the working code that I provide with this article and refactor it step by step until you arrive at the structure that you'd like to have.

  • #5 Gitau Harrison said 2022-06-26T02:37:21Z

    I noticed the error was a misspelling of the scripts block in the basic table template.

  • #6 Gitau Harrison said 2022-06-27T06:02:07Z

    I am curious how gridjs can allow for adding a button link, whose database value does not exist. For example, I have this table

    <table> <thead> <tr> <th>Expense</th> <th>Amount</th> <th>Link</th> </tr> </thead> <tbody> <tr> <td>Food</td> <td>5000</td> <td>[Button to delete entry]</td> </tr> <tr> <td>Fare</td> <td>8000</td> <td>[Button to delete entry]</td> </tr> </tbody> </table>

    Understandably, the expense item name and amount can be retrieved from the database. However, since the link is not in the model, only the dynamic href value can be used based on the item's id. I mean this:

    <a href="{{ url_for('view_function', id=item.id) }}"></a>
    

    A sample gridjs object would therefore look like this:

    new gridjs.Grid({
            columns: [
                { id: 'name', name: 'Item' },
                { id: 'date', name: 'Date' },
                { id: 'amount', name: 'Amount' },
                { id: 'action', name: 'Action' } // not in db
            ],
            data: [
                {% for item in budget_items %}
                    {
                        name: "{{ item.name }}",
                        amount: "{{ item.amount }}",
                        action: "<a href=' {{ url_for('budget_item_delete', id=item.id) }} ' class='btn btn-danger btn-xs'>Delete</a>"
                    },
                {% endfor %}
            ],
    });
    

    The table would be rendered as expected but with the link column having this kind of object being displayed <a href=' /delete/budget-item-17 ' class='btn btn-danger btn-xs'>Delete</a> instead of an actual delete button.

    A little lookup on Gridjs shows one example of how to add such a button, but I do not really understand it. How could this be done?

  • #7 Miguel Grinberg said 2022-06-27T23:08:05Z

    @Gitau: I don't have any code to share, but the idea is that you define a formatter function for these cells. Then Grid.js calls the function instead of rendering the column's value. In the function you can generate an HTML that you like, and that's what is rendered in the cell.

  • #8 Alex said 2022-06-28T16:09:22Z

    Great article! Just one correction. In "create_fake_users.py" the line: from app import db, User should be: from server_table import db, User

    I have also bought your React book. Keep up the good work!

  • #9 Hunter Kiely said 2022-07-18T05:26:51Z

    DataTables editor allows users to upload files to a specific column of a table. https://editor.datatables.net/examples/advanced/upload.html Is it possible to implement something like this with grid.js?

  • #10 Miguel Grinberg said 2022-07-18T09:46:22Z

    @Hunter: grid.js does not have editing support, I have built this portion of the tutorial myself. If you set the contents of a cell to a file field you should be able to upload files, but you'll have to handle everything yourself.

  • #11 zow said 2022-07-22T03:04:46Z

    hey, what if i wanted to get the column names straight from the sql database? so i can dynamically generate out the names? how do i go about doing that?

  • #12 Miguel Grinberg said 2022-07-22T13:28:42Z

    @zow: the columns are included in the HTML code, which is generated via Jinja templating. You can make the columns an argument into the template and generate them dynamically.

  • #13 Vel said 2022-07-23T12:27:04Z

    Also, How can we add an HTML snippet like the Delete button when we use server-side data?

  • #14 Miguel Grinberg said 2022-07-24T14:02:01Z

    @Vel: to render HTML you have to use a formatter function. For example, the following column definition renders the emails as a clickable link:

            columns: [
              { id: 'name', name: 'Name' },
              { id: 'age', name: 'Age' },
              { id: 'address', name: 'Address', sort: false },
              { id: 'phone', name: 'Phone Number', sort: false },
              { id: 'email', name: 'Email', formatter: (cell, row) => {
                return gridjs.html('<a href="mailto:' + cell + '">' + cell + '</a>');
              }},
    
  • #15 Ruben KOFFI said 2022-07-25T16:50:31Z

    Is it possible to add interfaces for adding and deleting data in tables ? (Editable Tables)

  • #16 Miguel Grinberg said 2022-07-25T17:55:15Z

    @Ruben: Yes, I don't see why not. You can combine this with regular forms, for example.

Leave a Comment