Beautiful Flask Tables, Part 2

Posted by
on under

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.

Become a Patron!

Hello, and thank you for visiting my blog! If you enjoyed this article, please consider supporting my work on this blog on Patreon!

73 comments
  • #26 Denis Cooper said

    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

    @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

    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

    @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

    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

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

  • #32 Dan Adiletta said

    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

    @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.

  • #34 Martin said

    Thank you for a great tutorial, I made it work even with my limited JS knowledge. I have an issue I could use your input on though: I have a column that contains the rowsum of two other columns, and I cannot figure out how to force a refresh of the table, when data is edit and posted. I can see in the /api/data that it is updated, by I need some dynamic updating of the table. Any suggestions?

  • #35 Miguel Grinberg said

    @Martin: I haven't looked into dynamically updating the table, so I don't know exactly how it's done, but it will definitely be something you'll do in the browser via JS, this cannot be done from the Python side unless you are okay with reloading the page to get the update.

  • #36 Rickard said

    @Dan Adiletta & @Miguel Grinberg.

    It is possible to include buttons. The issue is that I don't think it is possible to include {{url_for('name')}} in the JavaScript configuration. My solution was to hard code the html page and append the row ID to it, as such:

    { id: 'actions', name: 'Actions', sort:false, width:"8rem",
    formatter: (_, row) =>
    gridjs.html(<a href='/entities/edit/${row.cells[0].data}'><i class="fa-solid fa-pen-to-square"></i></a>)}
    ],

    Unfortunately I don't think there is a way around this, but rather that it's a limitation of the server-side implementation. This also means that you can't include form elements directly in the table which is unfortunate. If anyone else has managed to solve this more eloquently, please let us know.

  • #37 Miguel Grinberg said

    @Rickard: The url_for() function runs in the server, before the client receives anything. The URL that you are generating includes a JavaScript expression, which is going to be evaluated much later, when the template has been rendered and the browser is executing. So as you see, these two things do not happen at the same time or even in the same system, so they cannot be mixed. The best you can do is create a base URL using url_for(), and then later modify this base URL to include the variable portion coming from JavaScript.

  • #38 Rickard said

    @Miguel: thanks for the explanation, very much appreciate it. I adjusted the code a bit to include a url_for() call since I didn't want to use hard-coded values. It now looks like this instead:

    { id: 'actions', name: 'Actions', sort:false, width:"8rem",
    formatter: (_, row) =>
    gridjs.html(<a href='{{ url_for('entities.entities_edit', entity_id="") }}${row.cells[0].data}'><i class="fa-solid fa-pen-to-square"></i></a>)}
    ],

    The url_for() provides the base link with an empty argument that then gets added by having the row cell reference after the url_for() statement.

  • #39 AR said

    Hi Miguel, it seems moment.js dat rendering is not working with grid.js.

    I have a column name 'creation date', then i have following codes for grid.js :

    columns: [
    { id: 'creation_date', name: 'User Since' },

    AND,

    data: [
    {% for val in user_list %}
    {
    creation_date: '{{ moment(val.creation_date).format("MMM, Do, YYYY") }}',

    but it seems that instead of displaying nice dates, I am getting following response:

    <span class="flask-moment" data-timestamp="2020-02-24T10:42:49Z" data-function="format" data-format="MMM, Do, YYYY" data-refresh="0" style="display: none">2020-02-24T10:42:49Z</span>

    any suggestion?

  • #40 Miguel Grinberg said

    @AR: Flask-Moment uses HTML and JavaScript and is designed to work in the HTML portion of your page. You are putting it inside a JavaScript string, which is not going to work. I suggest you use the moment.js library directly since this is JavaScript code.

  • #41 Jim said

    Hi Miguel, thanks yet another excellent article.

    I bought your mega-flask tutorial and now using that alongside your gridjs article. I have an issue that I am using MSSQL as the database and the way in which server_table.html builds the 'query' means that SQLAlchemy complains about the query. This is the error message.

    sqlalchemy.exc.CompileError: MSSQL requires an order_by when using an OFFSET or a non-simple LIMIT clause

    Is it possible to change the order the query builds and is passed to server_table.html to build? Alternatively how can I get the query to build completely before being passed to the html to render?

    Thanks for your help

  • #42 Miguel Grinberg said

    @Jim: I'm not sure I understand what you are asking me. If the query that I show here does not work with your database server, then you should replace my query with an equivalent query that does the work in a way that is compatible with your database.

  • #43 Jim said

    @Miguel, sorry I have should have explained the issue better.

    When I run server_table.py using your code the console shows the query being built and the following GET's being posted.

    127.0.0.1 - - [07/Feb/2023 11:05:21] "GET / HTTP/1.1" 200 -
    127.0.0.1 - - [07/Feb/2023 11:05:22] "GET /api/data?start=0&length=10 HTTP/1.1" 200 -
    127.0.0.1 - - [07/Feb/2023 11:05:22] "GET /api/data?start=0&length=10 HTTP/1.1" 200 -
    127.0.0.1 - - [07/Feb/2023 11:05:23] "GET /api/data?sort=&sort=&start=0&length=10 HTTP/1.1" 200 -
    127.0.0.1 - - [07/Feb/2023 11:05:23] "GET /api/data?sort=&start=0&length=10 HTTP/1.1" 200 -
    127.0.0.1 - - [07/Feb/2023 11:05:23] "GET /api/data?sort=&sort=&sort=&start=0&length=10 HTTP/1.1" 200 -

    The issue I have is that when I use MSSQL instead of SQLLite, MSSQL does not allow the second statement (127.0.0.1 - - [07/Feb/2023 11:05:22] "GET /api/data?start=0&length=10 HTTP/1.1" 200 -) as it requires an order_by (sort) parameter (sqlalchemy.exc.CompileError: MSSQL requires an order_by when using an OFFSET or a non-simple LIMIT clause).

    So the question I have is: How can I build the GET statement up with Sort -> Pagination -> Filter, rather than Pagination -> Sort -> Filter.

    Thanks again for your help

  • #44 Miguel Grinberg said

    @Jim: Yes, and my answer is still the same. I have no experience with MSSQL nor a way to test queries against this database. You'll have to interpret the error message and try to create an equivalent query that works with this database. The error says you need to have an order_by clause. Have you tried specifying a sort order, for example? You can do this simply by clicking on a column header.

  • #45 Steve Wright said

    I am going to try and implement this to paginate a large table created by pandas. Have you tried this? Any advice? Thank you for all this great content!

  • #46 Miguel Grinberg said

    @Steve: I have not tried to use a pandas dataset, but as long as you replace the database calls with panda calls the result should be the same. Good luck!

  • #47 equggy said

    Hi Miguel. Would you kindly to help me?

    Im trying to create ahref for each cell in name column, but im stuck.

    I do something like this, but this doesn`t work.

    new gridjs.Grid({
        columns: [
          {
            id: 'name', name: 'Name', formatter: (cell) => {
              return gridjs.html('<a href=' + {{ url_for('user.member', name=cell) }} + '>' + cell + '</a>');
            }
          },
    
  • #48 Miguel Grinberg said

    @equggy: The grid.js formatters run in the browser side, not in the Python side. You cannot use url_for there. Use JavaScript to create your URLs and you should be fine.

  • #49 Edmund said

    Hi Miguel, have you ever considered doing a post about rich text or wysiwig editor integrations with flask and WTF? I've had a look at a couple of options for a simple rich text editor and they've not been very flask / wft friendly so far. Massive thanks as always! Ed

  • #50 Miguel Grinberg said

    @Edmund: An integration is probably not needed. The rich editor works entirely in the browser, Flask does not need to do anything there. The text will more than likely be in a text area form control, which Flask-WTF already supports.

Leave a Comment