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
  • #51 krisus said

    The search is broken. Remember the name from first subpage. Go to the other subpage and try searching for this name. You will get 0 results. Is there any way to fix this?

  • #52 Miguel Grinberg said

    @krisus: It's an open source project. If you have found an issue, file a bug with them. https://github.com/grid-js/gridjs

  • #53 James Baker said

    Hi Miguel,

    Amazing post! I love your work.

    I was wondering whether you could advise on whether it’s possible to filter the content in a server side table. I had a simple Grid JS table which I could filter the data based on a form on the same page. Each time the user pressed ‘search’ it would re load the page with a new set of data from the database in the table. However my data set is likely to become quite large and have now implemented the server version which works, but I can’t figure out how to best filter the data in the table in the same way either using a form or a function within Grid JS itself.

    Really appreciate any thoughts!

    Thanks,
    James

  • #54 Miguel Grinberg said

    @James: I don't understand the reason you ask me this question, since this post shows how to do exactly what you want, I think. Have you tried the server-side table example in this article?

  • #55 James Baker said

    Hi Miguel,

    Sorry my explanation probably wasn't too clear! I have the server side table working perfectly, pulling all of the data from my database as expected. On the same template in a sidebar I have a form where the user can select checkboxes to filter the data on submitting. This worked without the server side table as the page would refresh once the user clicked a 'submit' button, the database would then be queried based on the selected checkboxes and the table would be populated with only the filtered content. However with the server side table, the data is being pulled in from a seperate /data route with all database values being queried and displayed regardless of the form values being posted back to the route where I render my table. I can't figure out how to send my checkbox values from the form to my /data route on submit.

    I hope that makes more sense? Or perhaps there is an easier method I haven't thought of!

    Many thanks,
    James

  • #56 Miguel Grinberg said

    @James: When you receive the form submission with your filters you can add the values for those filters to your page template. The template should then include these filters in the URL that it configures as the endpoint to retrieve table rows. That way when the HTML page makes the Ajax request to retrieve the rows your endpoint receives the filters and can use them when querying the database.

  • #57 regnum said

    if I refer to the address http://127.0.0.1:5000/api/data is throws all the data from the database.
    Is it a right way to do things? (our html is available to anyone after all.)

    Also it would be nice to know, is it possible to implement connection via Websockets, not ajax, with your example? Thanks in advance.

  • #58 Miguel Grinberg said

    @regnum:

    if I refer to the address http://127.0.0.1:5000/api/data is throws all the data from the database.

    Yes, the same data that can be viewed when going to http://127.0.0.1:5000. So how is this a problem? You should protect your application with authentication and authorization so that people who don't have your permission do not access your server.

    Also it would be nice to know, is it possible to implement connection via Websockets, not ajax

    Grid.js uses ajax, I don't think they support WebSocket, but you are welcome to ask them if this is something they plan to do. I have no relation with the grid.js project outside of being a user.

  • #59 James said

    Hi Miguel, just wanted to say thanks! Managed to get it working by passing the filter variables in the Ajax request! Works like a dream!

  • #60 Cam said

    Hi Miguel,

    Excellent article.
    I use jinja to build out the columns dynamically for the table into the script, however, this command appears to invoke the data API sever call for every iteration of the column array - do you have a work around for this?

    ```
    new gridjs.Grid({
    columns: [
    {% for col in cols %}
    { id: '{{ col }}', name: '{{ col.upper() }}', sort: true }, // , width: '300px'
    {% endfor %}
    ],
    ````

  • #61 Miguel Grinberg said

    @Cam: I don't understand. Here you are just building the list of columns using a Jinja loop, which is fine. You should review the generated JavaScript to make sure it is correct, but other than that I see no problem. There is no reference to an API server in the code you pasted.

  • #62 Michael said

    Hello, I don't understand the search settings. What do I do if I want to filter and what are the cellindexes [0, 1, 4] for?

  • #63 Miguel Grinberg said

    @Michael: 0, 1 and 4 are the zero-based indexes of the columns you want to be included in the search.

  • #64 Dean Jino said

    Hi Miguel

    Can i do dynamic script in html and JS for my flask app cos when i add /remove columns this doesnt work

  • #65 Miguel Grinberg said

    @Dean: I suggest you review the documentation for Grid.js, because any advanced usages would need to be implemented in JavaScript and not from the Flask side, which can only do the initial render.

  • #66 Rick said

    Does this table allow for checkboxes or dropdowns to be a column type? I only see how adding regular text is an option in the documentation. I want to have a checkbox and utilize the editable feature to trigger JS/AJAX when the checkbox is checked.

  • #67 Miguel Grinberg said

    @Rick: I don't see why not, since you can add HTML in each cell. But I'm not the creator of gridjs and cannot offer support for it here. If you need help with this project, then use their support channels.

  • #68 Will Thong said

    Thanks so much for this article (and the initial Flask Mega-Tutorial) Miguel! I found your comment 56 in response to James Baker's question about adding filtering super useful. I had been struggling to understand how GridJS makes its API calls, but it turns out that directly editing that URL via Jinja works well (albeit requiring a page refresh). In case it can be of use to others, I've implemented a solution on my (very work-in-progress) Flask app: https://github.com/willthong/donation-whistle. (I definitely need to do some encapsulation using blueprints!)

  • #69 Eric said

    Thanks for the detailed post! If I have other select dropdowns on the page what is the best way to send that data back to the client side when grid.js makes the '/api/data/' call, so that I can use that data to modify my sql query? Thank you

  • #70 Miguel Grinberg said

    @Eric: The way to do this is probably to add your custom URL generation logic in the updateUrl() function.

  • #71 James Carr-Saunders said

    I'm very new at javascript so I'm unclear about lots of things but this blog is so helpful!

    Would this approach be vulnerable to a CSRF attack? I can't see any form.hidden_tag() or similar. Am I being over-protective?

  • #72 Miguel Grinberg said

    @James: CSRF attacks are all about triggering an action on the target website on behalf of the user that is the victim. These examples just display data in a table, there are no actions that can change the application state, so there is nothing that can be attacked via CSRF.

  • #73 Gibby said

    Thank you form your work Sir, I learn Python and your web page has been a great resource for me.

Leave a Comment