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.

33 comments

  • #26 Denis Cooper said 2022-10-04T20:07:01Z

    Great article - thank you...

    One bit I'm stuck on is how we can pass the data directly from a SQL alchemy query. I've got a query which queries several tables but getting an error that the data isn't serializable, query is like this...

    data = db.session.query(model).join(model1).join(model2).filter(model.column == value).all()

    how can I pass that to the server side return function?

    thanks

  • #27 Miguel Grinberg said 2022-10-05T10:14:47Z

    @Denis: You will need to convert any data that is not compatible with JSON to a string. This isn't a problem with your tables, but a requirement of the jsonify function from Flask.

  • #28 milovan said 2022-10-26T12:52:46Z

    Greetings and praises for every tutorial as well as books. How would I add a jinja filter to a field?

    {id: 'description'  | crop_data(10), name: 'DESCRIPTION'}
    
  • #29 Miguel Grinberg said 2022-10-26T21:58:52Z

    @milovan: In the more advanced table examples that I show here you can't add Jinja filters to a table cell, simply because the table is rendered in the browser, not by Jinja. You can only do this in the most basic example, which is the only one that gets the table rendered in the Jinja template.

  • #30 Techie said 2022-11-19T19:06:49Z

    Hello Miguel, DataTable supports grouping of rows.

    https://datatables.net/examples/advanced_init/row_grouping.html

    Would that be possible in this particular implementation !!

    Thanks for the great work.

  • #31 Miguel Grinberg said 2022-11-19T19:52:43Z

    @Techie: I'm using the standard library, without any restrictions, so yes, you can use any features of the library.

  • #32 Dan Adiletta said 2022-11-22T13:28:45Z

    Thanks for sharing! My tables include buttons with commands that require references to the row items' properties in their url_for calls. Is that possible with server tables?

  • #33 Miguel Grinberg said 2022-11-22T14:33:12Z

    @Dan: the Server Table example shows how to add HTML links in a cell. The same technique can be used to add any other HTML constructs. See here for the actual implementation.

Leave a Comment