Datatables

Introduction

Client-side grid.js script and server-side views.ajax.KoGridView Python class provide possibility to create AJAX-powered datatables (grids) for Django models, using underscore.js / knockout.js client-side templates.

It makes datatables from Django models similar to traditional django.contrib.admin built-in module, but provides the advantages of HTTP traffic minimization, multiple / nested widgets and custom client-side features such as compound columns.

views.ajax.KoGridView and views.list.ListSortingView have common ancestor class views.base.BaseFilterView, which allows to partially share the functionality between AJAX datatables and traditional paginated lists, although currently datatables (grids) are more featured and support wider variety of model field filters. See Built-in views for more info about views.list.ListSortingView.

knockout.js viewmodels are used to display / update AJAX datatables.

Each grid row represents an instance of associated Django model which can be browsed and manipulated by grid class.

There are key advantages of using AJAX calls to render Django Model datatables:

  • Minimization of HTTP traffic.
  • Possibility of displaying multiple datatables at the same web page and interact between them (for example update another datatable when current datatable is updated). See Grids interaction.
  • Custom filters / form widgets that may utilize nested AJAX datatables. See Grid fields, Filter fields.
  • In-place CRUD actions for grid rows using associated ModelForm and / or inline formsets with AJAX submission directly via BootstrapDialog. See Standard actions, widgets.ForeignKeyGridWidget.

Besides pagination of model data rows, default CRUD actions are supported and can be easily enabled for grids datatables. Custom grid actions for the whole grid as well as for the specific columns can be implemented by inheriting / extending Grid Javascript class and / or views.ajax.KoGridView Python class.

Possible ways of usage

  • AJAX datatables (grids) injected into Jinja2 templates as client-side components with ko_grid() macro.
  • Optional Foreign key filter for AJAX grid components.
  • Django ModelForm widget ForeignKeyGridWidget which provides ForeignKeyRawIdWidget - like functionality for ModelForm to select foreign key field value via AJAX query / response.
  • Pop-up AJAX datatable browser via GridDialog with optional editor.

Models used in this documentation

This documentation refers to Django models with one to many relationship defined in club_app.models:

from django_jinja_knockout.tpl import Str

# ... skipped ...

class Club(models.Model):
    CATEGORY_RECREATIONAL = 0
    CATEGORY_PROFESSIONAL = 1
    CATEGORIES = (
        (CATEGORY_RECREATIONAL, 'Recreational'),
        (CATEGORY_PROFESSIONAL, 'Professional'),
    )
    title = models.CharField(max_length=64, unique=True, verbose_name='Title')
    category = models.IntegerField(
        choices=CATEGORIES, default=CATEGORY_RECREATIONAL, db_index=True, verbose_name='Category'
    )
    foundation_date = models.DateField(db_index=True, verbose_name='Foundation date')

    class Meta:
        verbose_name = 'Sport club'
        verbose_name_plural = 'Sport clubs'
        ordering = ('title', 'category')

    def save(self, *args, **kwargs):
        if self.pk is None:
            if self.foundation_date is None:
                self.foundation_date = timezone.now()
        super().save(*args, **kwargs)

    def get_absolute_url(self):
        url = Str(reverse('club_detail', kwargs={'club_id': self.pk}))
        url.text = str(self.title)
        return url

    def get_str_fields(self):
        return OrderedDict([
            ('title', self.title),
            ('category', self.get_category_display()),
            ('foundation_date', format_local_date(self.foundation_date))
        ])

    def __str__(self):
        return ' › '.join(self.get_str_fields().values())


class Member(models.Model):
    SPORT_BADMINTON = 0
    SPORT_TENNIS = 1
    SPORT_TABLE_TENNIS = 2
    SPORT_SQUASH = 3
    SPORT_ANOTHER = 4
    BASIC_SPORTS = (
        (SPORT_BADMINTON, 'Badminton'),
        (SPORT_TENNIS, 'Tennis'),
        (SPORT_TABLE_TENNIS, 'Table tennis'),
        (SPORT_SQUASH, 'Squash'),
    )
    SPORTS = BASIC_SPORTS + ((SPORT_ANOTHER, 'Another sport'),)
    ROLE_OWNER = 0
    ROLE_FOUNDER = 1
    ROLE_MEMBER = 2
    ROLES = (
        (ROLE_OWNER, 'Owner'),
        (ROLE_FOUNDER, 'Founder'),
        (ROLE_MEMBER, 'Member'),
    )
    profile = models.ForeignKey(Profile, verbose_name='Sportsman')
    club = models.ForeignKey(Club, blank=True, verbose_name='Club')
    last_visit = models.DateTimeField(db_index=True, verbose_name='Last visit time')
    plays = models.IntegerField(choices=SPORTS, default=SPORT_ANOTHER, verbose_name='Plays sport')
    role = models.IntegerField(choices=ROLES, default=ROLE_MEMBER, verbose_name='Member role')
    note = models.TextField(max_length=16384, blank=True, default='', verbose_name='Note')
    is_endorsed = models.BooleanField(default=False, verbose_name='Endorsed')

    class Meta:
        unique_together = ('profile', 'club')
        verbose_name = 'Sport club member'
        verbose_name_plural = 'Sport club members'

    def get_absolute_url(self):
        url = Str(reverse('member_detail', kwargs={'member_id': self.pk}))
        str_fields = flatten_dict(self.get_str_fields(), enclosure_fmt=None)
        url.text = ' / '.join([str_fields['profile'], str_fields['club']])
        return url

    def get_str_fields(self):
        parts = OrderedDict([
            ('profile', self.profile.get_str_fields()),
            ('club', self.club.get_str_fields()),
            ('last_visit', format_local_date(timezone.localtime(self.last_visit))),
            ('plays', self.get_plays_display()),
            ('role', self.get_role_display()),
            ('is_endorsed', 'endorsed' if self.is_endorsed else 'unofficial')
        ])
        return parts

    def __str__(self):
        str_fields = self.get_str_fields()
        return str_dict(str_fields)

Simplest datatable

If you have Django model created and migrated, then it is quite easy to add grid for that model to Django app Jinja2 template, providing your templates are inherited from base_min.htm, or based on a custom-based template which includes the same client-side scripts as base_min.htm does.

In your app view code (we use club_app.views_ajax in this example) create the following view:

class SimpleClubGrid(KoGridView):

    model = Club
    grid_fields = '__all__'
    # Remove next line to disable columns sorting:
    allowed_sort_orders = '__all__'

Now let’s add an url name (route) in urls.py:

from django_jinja_knockout.urls import UrlPath
from club_app.views_ajax import SimpleClubGrid

# ... skipped ...

UrlPath(SimpleClubGrid)(
    name='club_grid_simple',
    kwargs={'view_title': 'Simple club grid', 'permission_required': 'club_app.change_club'}
),
# ... skipped ...

UrlPath automatically generates re_path pattern with named capture group <action> used by KoGridView.post() method for class-based view kwargs value HTTP routing to provide grid pagination and optional CRUD actions. Custom actions might be implemented via ancestor classes of KoGridView.

We assume that our datatable grid may later define actions which can change Club table rows, thus our view requires club_app.change_club permission from built-in django.contrib.auth module.

Our datatable grid is works just with few lines of code, but where is the template that generated initial HTML content?

By default, KoGridView uses built-in cbv_grid.htm template, which content looks like this:

{% from 'ko_grid.htm' import ko_grid with context %}
{% from 'ko_grid_body.htm' import ko_grid_body with context %}
{% extends 'base.htm' %}

{% block main %}

{{
ko_grid(
    grid_options={
        'pageRoute': view.request.resolver_match.url_name,
    }
)
}}

{% endblock main %}

{% block bottom_scripts %}
    {{ ko_grid_body() }}
{% endblock bottom_scripts %}

One may extend this template to customize grid, which we will do later.

Take a note that two Jinja2 macros are imported. Let’s explain their purpose.

ko_grid() macro

Jinja2 macro ko_grid() generates html code of client-side component which looks like this in the generated page html:

<a name="club_grid"></a>
<div class="component"
    data-component-class="ClubGrid"
    id="club_grid"
    data-component-options='{"defaultOrderBy": {"foundation_date": "-"}, "pageRoute": "club_grid_with_action_logging"}'
    data-template-args="{'show_pagination': true, 'show_title': true, 'vscroll': true}"
    data-template-id="ko_grid_body"
    data-template-options="{'meta_is_grid': true}">
</div>

The code is inserted into web page body block. This HTML is not the full DOM subtree of grid but an initial stub. It will be automatically expanded with the content of underscore.js template with name ko_grid_body by bindTemplates called via initClientHooks. See Underscore.js templates for more details.

At the next step, expanded DOM subtree will be automatically bound to newly created instance of Grid Javascript class via components class instance .add() method to make the grid “alive”.

See Component IoC how to register custom Javascript data-component-class, like ClubGrid mentioned above.

ko_grid() macro accepts the following kwargs:

  • Mandatory grid_options are client-side component options of current grid. It’s a dict with the following keys:

    • Mandatory key 'pageRoute' is used to get Python grid class in ko_grid() macro to autoconfigure client-side options of grid (see the macro code in ko_grid.htm for details).
    • The rest of the keys are optional and are passed to the constructor of Grid class. They could be used to modify grid appearance / behavior. See Grid class .init() method .options property for the current list of possible options. Some of these are:
      • alwaysShowPagination - set to False to show pagination controls only when there is more than one page of model instances are available.
      • expandFilterContents - whether the templates of datatable filters should be expanded as recursive underscore templates; by default is False.
      • defaultOrderBy - override initial order_by field name (by default Django model Meta.ordering is used).
      • highlightMode - built-in modes (See ‘switch_highlight’ action):
        • 'none' - do not highlight,
        • 'cycleColumns' - highlight columns with Bootstrap colors,
        • 'cycleRows' - highlight rows with Bootstrap colors,
        • 'linearRows' - highlight rows with CSS gradient,
      • preloadedMetaList - see ‘meta list’ action preload.
      • searchPlaceholder - text to display when search field is empty.
      • separateMeta - see ‘meta_list’ action and custom initial field filters.
      • showCompoundKeys - boolean, whether the names of Compound columns should be displayed in the grid cells;
      • showNonSortableColumnNames - show sortable column names only, hide non-sortable (non-clickable) column names, to minimize visual clutter for Compound columns headers
      • showSelection - enable selection of single rows (one model instance of grid).
      • ownerCtrl - used internally to embed client-side parts of datatables (grids) into another classes, for example into ForeignKeyGridWidget dialogs and Foreign key filter. The value of this option should be the instance of Javascript class, thus it is unused in server-side ko_grid() macro and should be provided in the inherited client-side class instead.
      • selectMultipleRows - set to True to enable multiple rows selection. Can be used to perform action with querysets of models, not just one Model instance. Use objects = self.get_queryset_for_action() in Django KoGridView derived CBV action handler to get the queryset with selected model instances. See action_delete implementation for example.
      • vScrollPage - whether datatable with "template_args": { "vscroll": true } should have it’s rows scrolled to the top after each page load; by default is True.
      • useInitClient - default value is null indicates use GridRow useInitClient value:
        • 0 - do not expand templates
        • 1 - transform bs attributes
        • 2 - full initClient
  • Optional template_args argument is passed as data-template-args attribute to underscore.js template, which is then used to alter visual layout of grid. In our case we assume that rows of club_app.Club may be visually long enough so we turn on vertical scrolling for these via "vscroll": true (which is off by default).

  • Optional dom_attrs argument is used to set extra DOM attributes of the component template:

    It may provide the value of component DOM id attribute which may then be used to get the instance of component (instance of Grid class). It is especially useful in the pages which define multiple datatables (grids) that interact to each other. See Grids interaction for more details.

    It also allows to pass custom values of template data-template-id, data-template-args, data-template-options html attributes used by template processor Tpl. See Underscore.js templates for more detail on these attributes usage. See also member_grid_tabs.htm for the example of overriding the template.

  • See ko_grid.htm for the source code of ko_grid() macro.

  • See components.js components instance for the details of client-side components implementation.

  • See tpl.js Tpl class for the details of client-side template processor implementation.

ko_grid_body() macro

ko_grid_body() macro, defined in ko_grid_body.htm is inserted into web page bottom scripts block. However it does not contain directly executed Javascript code, but a set of recursive underscore.js templates (such as ko_grid_body) that are applied automatically to each grid component DOM nodes, generated by before mentioned ko_grid() Jinja2 macro.

Since v2.0, es6 module loader with Component IoC is used to dynamically load Grid class, so the manual inclusion of grid.js script to Jinja2 / DTL templates is not required anymore. Only the Javascript Client-side entry points has to be specified. These entry points also may be used with django_deno app to generate IE11 compatible bundle and / or minified es6 bundle.

ko_grid_body() macro includes two versions of filter field widgets:

  • ko_grid_filter_choices / ko_grid_filter_popup used by default, when filter values are selected via bootstrap drop-down menus.

  • ko_grid_breadcrumb_filter_choices / ko_grid_breadcrumb_filter_popup, when filter values are displayed as bootstrap breadcrumbs. To activate this version of filter field widgets, one should call ko_grid_body() macro like this:

    {{
        ko_grid_body(
            include_ids=[
                'ko_grid_breadcrumb_filter_choices',
                'ko_grid_breadcrumb_filter_popup'
            ],
            exclude_ids=[
                'ko_grid_filter_choices',
                'ko_grid_filter_popup'
            ]
        )
    }}
    

    exclude_ids argument saves a bit of html removing unused underscore.js templates from the resulting page. It is also possible to have multiple grids datatables with different styles of filters at the same page. In such case exclude_ids argument should not be used.

  • then, generate grid like this:

    {{
    ko_grid(
        grid_options={
            'pageRoute': view.request.resolver_match.view_name,
            'pageRouteKwargs': view.kwargs,
        },
        dom_attrs={
            'data-template-options': {
                'templates': {
                    'ko_grid_filter_choices': 'ko_grid_breadcrumb_filter_choices',
                    'ko_grid_filter_popup': 'ko_grid_breadcrumb_filter_popup',
                }
            },
        }
    )
    }}
    

    There is cbv_grid_breadcrumbs.htm Jinja2 macro that could be used as template_name value of KoGridView derived grid class attribute to use breadcrumb-style filters. See sample project club_app.views_ajax for the example.

  • Since v2.2.0, Bootstrap navs style of Grid filter choices are available: ko_grid_navs_filter_choices / ko_grid_navs_filter_popup.

Grid configuration

Let’s see some more advanced grid sample for the club_app.models.Member, Django view part:

from django_jinja_knockout.views import KoGridView
from .models import Member

class MemberGrid(KoGridView):

    client_routes = {
        'member_grid',
        # url name (route) for 'profile' key of self.allowed_filter_fields
        'profile_fk_widget',
        # url name (route) for 'club' key of self.allowed_filter_fields
        'club_grid_simple'
    }
    # Use custom grid template instead of default 'cbv_grid.htm' template.
    template_name = 'member_grid.htm'
    model = Member
    grid_fields = [
        'profile',
        'club',
        # Compound columns:
        [
            # Will join 'category' field from related 'Club' table automatically via Django ORM.
            'club__category',
            'last_visit',
            'plays',
            'role',
        ],
        'note',
        'is_endorsed'
    ]
    # Will include all model field raw values to JSON response.
    exclude_fields = []
    search_fields = [
        ('club__title', 'icontains'),
        ('profile__first_name', 'icontains'),
        ('profile__last_name', 'icontains')
    ]
    allowed_sort_orders = [
        'club',
        'last_visit',
        'plays',
        'is_endorsed'
    ]
    allowed_filter_fields = OrderedDict([
        ('profile', None),
        ('club', None),
        ('last_visit', None),
        ('club__category', None),
        # Include only some Django model choices and disable multiple choices for 'plays' filter.
        ('plays', {
            'type': 'choices', 'choices': Member.BASIC_SPORTS, 'multiple_choices': False
        }),
        ('role', None),
        ('is_endorsed', None),
    ])

See club_app.views_ajax for the full sample.

Client-side response of KoGridView ‘list’ action returns only raw values of grid_fields by default.

  • To include all field values, set class-level attribute exclude_fields of KoGridView ancestor to empty list.
  • To exclude some sensitive field values from client-side exposure, add these to exclude_fields list.

Grid fields

Django model may have many fields, some of these having long string representation, thus visually grid may become too large to fit the screen and hard to navigate. Not all of the fields always has to be displayed.

Some fields may need to be hidden from user for security purposes. One also might want to display foreign key span relationships, which are implemented in Django ORM via '__' separator between related fields name, like club__category in this example.

Set Django grid class grid_fields property value to the list of model fields that will be displayed as grid columns. Spanned foreign key relationship are supported as well.

Grid fields dicts

Since v2.0, each value of grid_fields can be dict with the following keys:

  • field: mandatory name of Django Model field or a name of virtual field (Virtual fields).
  • name: optional localized name of field, displayed in datatable header.
  • virtual: optional boolean, which indicates that the current field is a virtual one (Virtual fields).

The example of defining both Grid fields dicts, Compound columns and Virtual fields:

from django.utils.translation import gettext as _

from django_jinja_knockout.views import KoGridView

class ControlGrid(KoGridView):

    model = Control

    grid_fields = [
        # Three compound columns:
        [
            'control__start_date',
            'ctrl_id',
            # Virtual field with custom local verbose name
            {'field': 'ctrl_set__count', 'name': _('Number of controls'), 'virtual': True},
        ],
        # Two "ordinary" columns:
        'start_date',
        'finish_date`,
        # Two compound columns. Each field has relation spans.
        [
            'control__decline_threshold',
            'control__growth_threshold',
        ],
    ]

See club_app.views_ajax for the actual examples of using grid_fields dict values.

Compound columns

Compound columns are supported. In the example above, 8 fields will be displayed in 5 columns, conserving horizontal display space of datatable row:

MemberGrid
‘profile’ ‘club’

‘club__category’

‘last_visit’

‘plays’

‘role’

‘note’ ‘is_endorsed’
profile1 club1

club__category1

last_visit1

plays1

role1

note1 is_endorsed1
profile2 club2

club__category2

last_visit2

plays2

role2

note2 is_endorsed2

profile / club / note fields visual display can take lots of screen space, because first two are foreign fields, while note is a TextField, thus these are rendered in separate columns of datatable.

club_category / last_visit / plays / role fields visual display is short, thus these are grouped into single compound column to preserve display space.

is_endorsed field does not take lots of space, however it’s a very important one, thus is displayed in separate column.

Traditional non-AJAX views.list.ListSortingView also supports compound columns with the same definition syntax:

class ActionList(ContextDataMixin, ListSortingView):
    # Enabled always visible paginator links because there could be many pages of actions, potentially.
    always_visible_links = True
    model = Action
    grid_fields = [
        [
            'performer',
            'performer__is_superuser',
            'date',
        ],
        'action_type',
        'content_object'
    ]
    allowed_sort_orders = [
        'performer',
        'date',
        'action_type',
    ]

    def get_allowed_filter_fields(self):
        allowed_filter_fields = {
            'action_type': None,
            'content_type': self.get_contenttype_filter(
                ('club_app', 'club'),
                ('club_app', 'equipment'),
                ('club_app', 'member'),
            )
        }
        return allowed_filter_fields

Nested verbose field names

Grid datatables and grid-based classes like ForeignKeyGridWidget support displaying verbose / localized field names of Django model instances with their values, including foreign key related model fields. It is supported in the following cases:

  • Related model fields display in grid cells;
  • Grid row actions;
  • ForeignKeyGridWidget display of chosen fk value;
  • Client-side support of field names display is added into renderNestedList via options . i18n mapping.
  • Server-side support of rendering verbose field names is implemented in:
    • tpl module print_list() function now supports optional show_keys / i18n arguments.
    • models module functions used to gather verbose field names of Django model:
      • model_fields_meta() - get fields verbose names of the selected model;
      • yield_related_models() - get related models of the selected model;
    • views.ajax.GridActionsMixin class:
      • get_model_fields_verbose_names() - get current grid Django model fields verbose names.
      • get_related_model_fields_verbose_names() - get related models fields verbose names.
      • get_related_models() returns the list of related models.

The list of current model verbose field names is returned by ‘meta’ action as value of meta . listOptions property, while the list of related models fields verbose names is returned as value of meta . fkNestedListOptions property.

By default the list of related models fields verbose names is collected automatically, but in case grid model has generic relationships, these can be specified manually via class-level related_models property like this:

from .models import Action, Club, Equipment, Manufactures, Member, Profile
from django_jinja_knockout.views import KoGridView
# ... skipped ...

class ActionGrid(KoGridView):

    client_routes = {
        'user_fk_widget'
    }
    model = Action
    grid_fields = [
        'performer',
        'date',
        'action_type',
        'content_type',
        'content_object'
    ]
    # Autodetection of related_models is impossible because Action model has generic relationships.
    related_models = [Club, Equipment, Manufacturer, Member, Profile]

    # ... skipped ...

Relation prefixes club, equipment and so on will be automatically prepended to related models verbose names to avoid the name clash in case different related models fields having the same field name but a different verbose name.

See event_app.views_ajax ActionGrid class for the full example.

It is possible to specify relation prefix manually with related_models initialized as dict. To use repeated prefix, initialize grid related_models class level property as the list of tuple pairs:

from .models import EventLog, Club, Equipment, Member
from django_jinja_knockout.views import KoGridView
# ... skipped ...

class EventLogGrid(KoGridView):

    model = EventLog
    grid_fields = [
        'user__username',
        'content_object',
        'content_type',
    ]
    allowed_sort_orders = [
        'user__username',
        'content_type',
    ]
    search_fields = [
        ('user__username', 'icontains'),
    ]
    related_models = [
        ('content_object', Club),
        ('content_object', Equipment),
        ('content_object', Member),
    ]
    # ... skipped ...

To override automatic collecting of Django model verbose field names, one has to define Django model @classmethod get_fields_i18n, which should return a dict with keys as field names and values as their verbose / localized names.

Customizing visual display of fields at client-side

To alter visual representation of grid row cells, one should override GridRow Javascript class .display() method, to implement custom display layout of field values at client-side. The same method also can be used to generate condensed representations of long text values via Bootstrap popovers, or even to display fields as form inputs: using grid as paginated AJAX form - (which is also possible but requires writing custom underscore.js grid layout templates, partially covered in modifying_visual_layout_of_grid):

import { inherit } from '../../djk/js/dash.js';
import { Grid } from '../../djk/js/grid.js';
import { GridRow } from '../../djk/js/grid/row.js';

MemberGridRow = function(options) {
    inherit(GridRow.prototype, this);
    this.init(options);
};

(function(MemberGridRow) {

   // 0 - do not expand templates, 1 - transform bs attributes, 2 - full initClient
    MemberGridRow.useInitClient = 2;

    MemberGridRow.display = function(field) {
        var displayValue = this._super._call('display', field);
        switch (field) {
        case 'role':
            // Display field value as bootstrap label.
            var types = ['success', 'info', 'primary'];
            displayValue = $('<span>', {
                'class': 'label preformatted'
            })
            .text(displayValue)
            .addClass(
                'label-' + (this.values[field] < types.length ? types[this.values[field]] : 'info')
            );
            break;
        case 'note':
            // Display field value as bootstrap clickable popover.
            var gridColumn = this.ownerGrid.getKoGridColumn(field);
            if (this.values[field] !== '') {
                displayValue = $('<button>', {
                    'class': 'btn btn-info',
                    'data-content': this.values[field],
                    'data-toggle': 'popover',
                    'data-trigger': 'click',
                    'data-placement': 'bottom',
                    'title': gridColumn.name,
                }).text('Full text');
            }
            break;
        case 'is_endorsed':
            // Display field value as form input.
            var attrs = {
                'type': 'checkbox',
                'class': 'form-field club-member',
                'data-pkval': this.getValue(this.ownerGrid.meta.pkField),
                'name': field + '[]',
            };
            if (this.values[field]) {
                attrs['checked'] = 'checked';
            }
            displayValue = $('<input>', attrs);
        }
        return displayValue;
    };

})(MemberGridRow.prototype);


MemberGrid = function(options) {
    inherit(Grid.prototype, this);
    this.init(options);
};

(function(MemberGrid) {

    MemberGrid.iocRow = function(options) {
        return new MemberGridRow(options);
    };

})(MemberGrid.prototype);

See member-grid.js for full-size example.

GridRow class .display() method used in grid.js grid_compound_cell binding supports the following types of values:

  • jQuery objects, whose set of elements will be added to cell DOM

get_str_fields model formatting / serialization

  • Nested list of values, which is automatically passed to client-side in AJAX response by KoGridView when current Django model has get_str_fields() method implemented. This method returns str() representation of some or all model fields:

    class Member(models.Model):
    
        # ... skipped ...
    
        # returns the list of str() values for all or some of model fields,
        # optionally spanning relationships via nested lists.
        def get_str_fields(self):
            parts = OrderedDict([
                ('profile', self.profile.get_str_fields()),
                ('club', self.club.get_str_fields()),
                ('last_visit', format_local_date(timezone.localtime(self.last_visit))),
                ('plays', self.get_plays_display()),
                ('role', self.get_role_display()),
                ('is_endorsed', 'endorsed' if self.is_endorsed else 'unofficial')
            ])
            return parts
    
        # It's preferable to reconstruct model's str() via get_str_fields() to keep it DRY.
        def __str__(self):
            str_fields = self.get_str_fields()
            return str_dict(str_fields)
    

Model.get_str_fields() will also be used for automatic formatting of scalar fields via grid row str_fields property. See ‘list’ action for more info.

  • Scalar values usually are server-side Django generated strings. Make sure these strings do not contain unsafe HTML to prevent XSS. Here’s the sample implementation in the version 2.0:

    import { renderValue } from '../../djk/js/nestedlist.js';
    
    // Supports jQuery elements / nested arrays / objects / HTML strings as grid cell value.
    GridColumnOrder.renderRowValue = function(element, value) {
        renderValue(element, value, this.getNestedListOptions());
    };
    

Nested list values are escaped by default in GridRow.htmlEncode(), thus are not escaped twice in GridColumnOrder.renderRowValue(). This allows to have both escaped and unescaped nested lists in row cells with Grid mark_safe_fields attribute list that allows to disable HTML escaping for the selected grid fields.

Since v2.1.0 it’s preferable to implement custom serialization via ObjDict class get_str_fields() method. The instantiation of serializer is performed via ObjDict.from_obj static method.

See ObjDict serialization just below.

ObjDict serialization

Since v2.1.0, all low-level serialization is performed either via default ObjDict or derived class. It incorporates both serialized Django Model instance fields as dict key / value pairs and self.obj attribute as the instance itself. To perform custom serialization and / or to implement field permissions filters, one has to inherit from ObjDict class then specify the child class name as the models.Model Meta.obj_dict_cls attribute value:

from django_jinja_knockout.obj_dict import ObjDict

class ActionObjDict(ObjDict):

    def can_view_field(self, field_name=None):
        return self.request_user is None or self.request_user == self.obj.performer or self.request_user.is_superuser

    def get_str_fields(self):
        return OrderedDict([
            ('performer', self.obj.performer.username),
            ('date', format_local_date(self.obj.date) if self.can_view_field() else site.empty_value_display),
            ('action_type', self.obj.get_action_type_display()),
            ('content_type', str(self.obj.content_type)),
            (
                'content_object',
                site.empty_value_display
                if self.obj.content_object is None
                else ObjDict(obj=self.obj.content_object, request_user=self.request_user).get_description()
            )
        ])


class Action(models.Model):

    TYPE_CREATED = 0
    TYPE_MODIFIED = 1
    TYPES = (
        (TYPE_CREATED, 'Created'),
        (TYPE_MODIFIED, 'Modified'),
    )

    performer = models.ForeignKey(User, on_delete=models.CASCADE, related_name='+', verbose_name='Performer')
    date = models.DateTimeField(verbose_name='Date', db_index=True)
    action_type = models.IntegerField(choices=TYPES, verbose_name='Type of action')
    content_type = models.ForeignKey(
        ContentType, blank=True, null=True, on_delete=models.CASCADE,
        related_name='related_content', verbose_name='Related object'
    )
    object_id = models.PositiveIntegerField(blank=True, null=True, verbose_name='Object link')
    content_object = GenericForeignKey('content_type', 'object_id')

    class Meta:
        verbose_name = 'Action'
        verbose_name_plural = 'Actions'
        ordering = ('-date',)
        obj_dict_cls = ActionObjDict

    def get_str_fields(self):
        return ObjDict.from_obj(obj=self).get_str_fields()

    def __str__(self):
        str_fields = self.get_str_fields()
        return str_dict(str_fields)

Note that Action.get_str_fields() method will automatically instantiate specified Meta.obj_dict_cls = ActionObjDict class, then will call ActionObjDict instance .get_str_fields() method.

See djk-sample event_app.models for the complete example of custom ObjDict serialization class with user permission check.

ObjDict class request_user constructor optional argument may be set to filter the visibility of fields per user in the overridden ObjDict.get_str_fields() method, where request_user is available (not models but views / forms).

Client-side class overriding

To override client-side class to MemberGrid instead of default Grid class, define default grid options like this:

from django_jinja_knockout.views import KoGridView
from .models import Member

# ... skipped ...

class MemberGrid(KoGridView):

    model = Member
    # ... skipped ...
    grid_options = {
        'classPath': 'MemberGrid'
    }

See Component IoC how to register custom Javascript classPath, like MemberGrid mentioned above.

Virtual fields

views.KoGridView also supports virtual fields, which are not real database table fields, but a calculated values. It supports both SQL calculated fields via Django ORM annotations and virtual fields calculated in Python code. To implement virtual field(s), one has to override the following methods in the grid child class:

class ClubGridWithVirtualField(SimpleClubGrid):

    grid_fields = [
        'title',
        'category',
        'foundation_date',
        # Annotated field.
        'total_members',
        # Virtual field.
        'exists_days'
    ]

    def get_base_queryset(self):
        # Django ORM annotated field 'total_members'.
        return super().get_base_queryset().annotate(total_members=Count('member'))

    def get_field_verbose_name(self, field_name):
        if field_name == 'exists_days':
            # Add virtual field.
            return 'Days since foundation'
        elif field_name == 'total_members':
            # Add annotated field.
            return 'Total members'
        else:
            return super().get_field_verbose_name(field_name)

    def get_related_fields(self, query_fields=None):
        query_fields = super().get_related_fields(query_fields)
        # Remove virtual field from queryset values().
        query_fields.remove('exists_days')
        return query_fields

    def get_model_fields(self):
        model_fields = copy(super().get_model_fields())
        # Remove annotated field which is unavailable when creating / updating single object which does not uses
        # self.get_base_queryset()
        # Required only because current grid is editable.
        model_fields.remove('total_members')
        return model_fields

    def postprocess_row(self, row, obj):
        # Add virtual field value.
        row['exists_days'] = (timezone.now().date() - obj.foundation_date).days
        if 'total_members' not in row:
            # Add annotated field value which is unavailable when creating / updating single object which does not uses
            # self.get_base_queryset()
            # Required only because current grid is editable.
            row['total_members'] = obj.member_set.count()
        row = super().postprocess_row(row, obj)
        return row

    # Optional formatting of virtual field (not required).
    def get_row_str_fields(self, obj, row):
        str_fields = super().get_row_str_fields(obj, row)
        if str_fields is None:
            str_fields = {}
        # Add formatted display of virtual field.
        is_plural = pluralize(row['exists_days'], arg='days')
        str_fields['exists_days'] = '{} {}'.format(row['exists_days'], 'day' if is_plural == '' else is_plural)
        return str_fields

See club_app.views_ajax code for full implementation.

Filter fields

Grid supports different types of filters for model fields, to reduce paginated queryset, which helps to locate specific data in the whole model’s database table rows set.

Full-length as well as shortcut definitions of field filters are supported:

from collections import OrderedDict
from django_jinja_knockout.views import KoGridView
from .models import Model1


class Model1Grid(KoGridView):
    # ... skipped ...

    allowed_filter_fields = OrderedDict([
        (
            # Example of complete filter definition for field type 'choices':
            'field1',
            {
                'type': 'choices',
                'choices': Model1.FIELD1_CHOICES,
                # Do not display 'All' choice which resets the filter:
                'add_reset_choice': False,
                # List of choices that are active by default:
                'active_choices': ['field1_value_1'],
                # Do not allow to select multiple choices:
                'multiple_choices': False
            },
        ),
        # Only some of filter properties are defined, the rest are auto-guessed:
        (
            'field2',
            {
                # Commented out to autodetect field type:
                # 'type': 'choices',
                # Commented out to autodetect field.choices:
                # 'choices': Model1.FIELD1_CHOICES,
                # Is true by default, thus switching to False:
                'multiple_choices': False
            }
        ),
        # Try to autodetect field filter completely:
        ('field3', None),
        # Custom choices filter (not necessarily matching Model1.field4 choices):
        ('field4', CUSTOM_CHOICES_FOR_FIELD4),
        # Select foreign key choices via AJAX grid built into BootstrapDialog.
        # Can be replaced to ('model2_fk', None) to autodetect filter type,
        # but explicit type might be required when using IntegerField as foreign key.
        ('model2_fk', {
            'type': 'fk'
        }),
    ])

Next types of built-in field filters are available:

Range filters

  • 'number' filter / 'datetime' filter / 'date' filter: Uses RangeFilter / GridRangeFilter to display dialog with range of scalar values. It’s applied to the corresponding Django model scalar fields.

Choices filter

  • 'choices' filter is used by default when Django model field has choices property defined, like plays and role fields in the next example:

    from django.utils.translation import ugettext as _
    # ... skipped ...
    
    class Member(models.Model):
        SPORT_BADMINTON = 0
        SPORT_TENNIS = 1
        SPORT_TABLE_TENNIS = 2
        SPORT_SQUASH = 3
        SPORT_ANOTHER = 4
        BASIC_SPORTS = (
            (SPORT_BADMINTON, 'Badminton'),
            (SPORT_TENNIS, 'Tennis'),
            (SPORT_TABLE_TENNIS, 'Table tennis'),
            (SPORT_SQUASH, 'Squash'),
        )
        SPORTS = BASIC_SPORTS + ((SPORT_ANOTHER, 'Another sport'),)
        ROLE_OWNER = 0
        ROLE_FOUNDER = 1
        ROLE_MEMBER = 2
        ROLES = (
            (ROLE_OWNER, 'Owner'),
            (ROLE_FOUNDER, 'Founder'),
            (ROLE_MEMBER, 'Member'),
        )
        profile = models.ForeignKey(Profile, verbose_name='Sportsman')
        club = models.ForeignKey(Club, blank=True, verbose_name='Club')
        last_visit = models.DateTimeField(db_index=True, verbose_name='Last visit time')
        plays = models.IntegerField(choices=SPORTS, default=SPORT_ANOTHER, verbose_name='Plays sport')
        role = models.IntegerField(choices=ROLES, default=ROLE_MEMBER, verbose_name='Member role')
        note = models.TextField(max_length=16384, blank=True, default='', verbose_name='Note')
        is_endorsed = models.BooleanField(default=False, verbose_name='Endorsed')
    

'choices' filter is also automatically populated when the field is an instance of BooleanField / NullBooleanField.

When using 'choices' filter for a grid column (Django model field), instance of GridFilter will be created at client-side, representing a dropdown with the list of possible choices from the Club.CATEGORIES tuple above:

from django_jinja_knockout.views import KoGridView
from .models import Member

class MemberGrid(KoGridView):

    model = Member
    # ... skipped ...

    allowed_filter_fields = OrderedDict([
        ('profile', None),
        ('club', None),
        ('last_visit', None),
        ('club__category', None),
        # Include all Django model field choices, multiple selection will be auto-enabled
        # when there are more than two choices.
        ('plays', None),
        ('role', None),
        ('is_endorsed', None),
    ])

Choices can be customized by supplying a dict with additional keys / values. See play field filter in the next example:

class MemberGrid(KoGridView):

    model = Member
    # ... skipped ...

    allowed_filter_fields = OrderedDict([
        ('profile', None),
        ('club', None),
        ('last_visit', None),
        ('club__category', None),
        # Include only limited BASIC_SPORTS Django model field choices
        # and disable multiple choices for 'plays' filter.
        ('plays', {
            'type': 'choices', 'choices': Member.BASIC_SPORTS, 'multiple_choices': False
        }),
        ('role', None),
        ('is_endorsed', None),
    ])

Query filters support arrays of choices for filter value:

class MemberGrid(KoGridView):

    model = Member
    # ... skipped ...

    allowed_filter_fields = OrderedDict([
        (
            'is_endorsed',
            {
                'choices': ((True, 'Active'), ([None, False], 'Candidate')),
            }
        )
    ])

When user will select Candidate choice from the drop-down list, two filters will be applied: None or False.

Foreign key filter

  • 'fk' filter: Uses GridDialog to select filter choices of foreign key field. This widget is similar to ForeignKeyRawIdWidget defined in django.contrib.admin.widgets that is used via raw_id_fields django.admin class option. Because it completely relies on AJAX calls, one should create grid class for the foreign key field, for example:

    class ProfileFkWidgetGrid(KoGridView):
    
        model = Profile
        form = ProfileForm
        enable_deletion = True
        grid_fields = ['first_name', 'last_name']
        allowed_sort_orders = '__all__'
    

Define it’s url name (route) in urls.py via UrlPath:

from django_jinja_knockout.urls import UrlPath

UrlPath(ProfileFkWidgetGrid)(
    name='profile_fk_widget',
    # kwargs={'permission_required': 'club_app.change_profile'}
),

Now, to bind ‘fk’ widget for field Member.profile to profile-fk-widget url name (route):

class MemberGrid(KoGridView):

    client_routes = {
        'member_grid',
        'profile_fk_widget',
        'club_grid_simple'
    }
    template_name = 'member_grid.htm'
    model = Member
    grid_fields = [
        'profile',
        'club',
        'last_visit',
        'plays',
        'role',
        'note',
        'is_endorsed'
    ]
    allowed_filter_fields = OrderedDict([
        ('profile', None),
        ('club', None),
        ('last_visit', None),
        ('plays', None),
        ('role', None),
        ('is_endorsed', None),
    ])

    # ... skipped ...

    # Similar to class property grid_options but allows to generate options dynamically and to override them.
    @classmethod
    def get_grid_options(cls):
        return {
            # Note: 'classPath' is not required for standard Grid.
            'classPath': 'MemberGrid',
            'searchPlaceholder': 'Search for club or member profile',
            'fkGridOptions': {
                'profile': {
                    'pageRoute': 'profile_fk_widget'
                },
                'club': {
                    'pageRoute': 'club_grid_simple',
                    # Optional setting for BootstrapDialog:
                    'dialogOptions': {'size': 'size-wide'},
                    # Nested filtering is supported:
                    # 'fkGridOptions': {
                    #     'specialization': {
                    #         'pageRoute': 'specialization_grid'
                    #     }
                    # }
                }
            }
        }

Explicit definition of fkGridOptions in get_grid_options() result is not required, but it’s useful to illustrate how foreign key filter widgets are nested:

  • Define model Specialization.
  • Add foreignKey field specialization = models.ForeignKey(Specialization, verbose_name='Specialization') to Profile model.
  • Create SpecializationGrid with model = Specialization.
  • Add url for SpecializationGrid with url name (route) 'specialization_grid' to urls.py.
  • Append 'specialization_grid' entry to class MemberGrid attribute client_routes set.

KoGridView is able to autodetect fkGridOptions of foreign key fields when these are specified in allowed_filter_fields (see discover_grid_options for the implementation), making definitions of foreign key filters shorter and more DRY:

class MemberGrid(KoGridView):

    client_routes = {
        'member_grid',
        'profile_fk_widget',
        'club_grid_simple'
    }
    template_name = 'member_grid.htm'
    model = Member
    grid_fields = [
        'profile',
        'club',
        'last_visit',
        'plays',
        'role',
        'note',
        'is_endorsed'
    ]
    allowed_filter_fields = OrderedDict([
        ('profile', {
            'pageRoute': 'profile_fk_widget'
        }),
        # When 'club_grid_simple' grid view has it's own foreign key filter fields, these will be automatically
        # detected - no need to specify these in .get_grid_options() as nested dict.
        ('club', {
            'pageRoute': 'club_grid_simple',
            # Optional setting for BootstrapDialog:
            'dialogOptions': {'size': 'size-wide'},
        }),
        ('last_visit', None),
        ('plays', None),
        ('role', None),
        ('is_endorsed', None),
    ])
    grid_options = {
        # Note: 'classPath' is not required for standard Grid.
        'classPath': 'MemberGrid',
        'searchPlaceholder': 'Search for club or member profile',
    }

See Component IoC how to register custom Javascript classPath, like MemberGrid mentioned above.

Dynamic generation of filter fields

There are many cases when datatables require dynamic generation of filter fields and their values:

  • Different types of filters for end-users depending on their permissions.
  • Implementing base grid pattern, when there is a base grid class defining base filters, and few child classes, which may alter / add / delete some of the filters.
  • 'choices' filter values might be provided via Django database queryset.
  • 'choices' filter values might be generated as foreign key id’s for Django contenttypes framework generic models relationships.

Let’s explain the last case as the most advanced one.

Generation of 'choices' filter list of choice values for Django contenttypes framework is implemented via BaseFilterView.get_contenttype_filter() method, whose class is a base class to both KoGridView and it’s traditional request counterpart ListSortingView (see views for details).

We want to implement generic action logging, similar to django.admin logging but visually displayed as AJAX grid. Our Action model, defined in event_app.models looks like this:

from collections import OrderedDict

from django.utils import timezone
from django.db import models
from django.db import transaction
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType

from django_jinja_knockout.tpl import format_local_date
from django_jinja_knockout.utils.sdv import flatten_dict, str_dict

class Action(models.Model):

    TYPE_CREATED = 0
    TYPE_MODIFIED = 1
    TYPES = (
        (TYPE_CREATED, 'Created'),
        (TYPE_MODIFIED, 'Modified'),
    )

    performer = models.ForeignKey(User, related_name='+', verbose_name='Performer')
    date = models.DateTimeField(verbose_name='Date', db_index=True)
    action_type = models.IntegerField(choices=TYPES, verbose_name='Type of action')
    content_type = models.ForeignKey(ContentType, related_name='related_content', blank=True, null=True,
                                     verbose_name='Related object')
    object_id = models.PositiveIntegerField(blank=True, null=True, verbose_name='Object link')
    content_object = GenericForeignKey('content_type', 'object_id')

    class Meta:
        verbose_name = 'Action'
        verbose_name_plural = 'Actions'
        ordering = ('-date',)

    # ... skipped ...

To allow queryset filtering via ‘content_object’ field 'choices' filter (Choices filter), ActionGrid overrides get_allowed_filter_fields() method to generate 'choices' filter values from contenttypes framework by calling get_contenttype_filter() method:

from collections import OrderedDict
from django.utils.html import format_html
from django_jinja_knockout.views import KoGridView
from .models import Action

class ActionGrid(KoGridView):

    model = Action
    grid_fields = [
        'performer',
        'date',
        'action_type',
        # Note that generic object relationship field is treated as virtual field because Django ORM does not
        # allow to perform values() method on querysets which have such fields.
        'content_object'
    ]
    allowed_sort_orders = [
        'performer',
        'date',
        'action_type',
    ]
    mark_safe_fields = [
        'content_object'
    ]
    enable_deletion = True

    def get_allowed_filter_fields(self):
        allowed_filter_fields = OrderedDict([
            ('action_type', None),
            # Get names / ids of 'content_type' choices filter.
            ('content_type', self.get_contenttype_filter(
                ('club_app', 'club'),
                ('club_app', 'equipment'),
                ('club_app', 'member'),
            ))
        ])
        return allowed_filter_fields

    def get_related_fields(self, query_fields=None):
        query_fields = super().get_related_fields(query_fields)
        # Remove virtual field from queryset values().
        query_fields.remove('content_object')
        return query_fields

    def postprocess_row(self, row, obj):
        # Add virtual field value.
        content_object = obj.content_object
        row['content_object'] = content_object.get_str_fields() \
            if hasattr(content_object, 'get_str_fields') \
            else str(content_object)
        row = super().postprocess_row(row, obj)
        return row

    # Optional formatting of virtual field (not required).
    def get_row_str_fields(self, obj, row=None):
        str_fields = super().get_row_str_fields(obj, row)
        if str_fields is None:
            str_fields = {}
        # Add formatted display of virtual field.
        if hasattr(obj.content_object, 'get_absolute_url'):
            link = obj.content_object.get_absolute_url()
            str_fields['content_type'] = format_html(
                '<a href="{}" target="_blank">{}</a>',
                link,
                str_fields['content_type']
            )
        return str_fields

See event_app.views_ajax for the complete example.

Modifying visual layout of grid

Top DOM nodes of grid component can be overridden by using Jinja2 {% call(kwargs) ko_grid() %} statement, then implementing a caller section with custom DOM nodes. See the source code of ko_grid.htm template for original DOM nodes of Grid component. This feature is rarely used since version 0.5.0 rewritten template processor offers more simpler ways to override root ko_grid_body underscore.js template at client-side.

It is possible to override some or all underscore.js templates of Grid component. ko_grid() macro allows to override built-in grid templates with custom ones by providing dom_attrs argument with 'data-template-options' attribute key / values. In the example just below 'member_ko_grid_filter_choices' and 'member_ko_grid_body' will be called instead of default templates.

When custom grid templates are defined, one may wish not to include unused standard grid templates. To include only selected standard grid templates, there are optional arguments of ko_grid_body() Jinja2 macro with the lists of template names.

  • Optional 'include_ids' argument list of built-in nested templates DOM ids that will be included into generated html page.
  • Optional 'exclude_ids' argument list of built-in nested templates DOM ids to be skipped from generated html page.

Here is the example of overriding visual display of GridFilter that is used to select filter field from the list of specified choices. ko_grid_body underscore.js template is overridden to member_ko_grid_body template with button inserted that has knockout.js custom binding:

"click: onChangeEndorsement"

Full code:

{% from 'ko_grid.htm' import ko_grid with context %}
{% from 'ko_grid_body.htm' import ko_grid_body with context %}
{% extends 'base.htm' %}

{% block main %}
    {#
        'separateMeta' is required because Django grid specifies 'active_choices' field filter value.
    #}
    {#
        Overwrites templates for custom display of MemberGrid.
    #}
    {{ ko_grid(
        grid_options={
            'pageRoute': view.request.resolver_match.url_name,
            'separateMeta': True,
        },
        template_args={
            'vscroll': True
        },
        dom_attrs={
            'id': 'member_grid',
            'data-template-options': {
                'templates': {
                    'ko_grid_body': 'member_ko_grid_body',
                    'member_ko_grid_nav': 'ko_grid_nav',
                    'ko_grid_filter_choices': 'member_ko_grid_filter_choices',
                }
            },
        }
    ) }}

{% do page_context.set_custom_scripts(
    'sample/js/member-grid.js',
) -%}

{% endblock main %}

{% block bottom_scripts %}
    {# Generate standard grid templates for KoGridWidget #}
    {{ ko_grid_body() }}

    <script type="text/template" id="member_ko_grid_body">
        <card-primary data-bind="using: $root, as: 'grid'">
            <card-header data-bind="text: meta.verboseNamePlural"></card-header>
            <card-body>
                <!-- ko if: meta.hasSearch() || gridFilters().length > 0 -->
                <div data-template-id="member_ko_grid_nav"></div>
                <!-- /ko -->
                <div data-template-id="ko_grid_table"></div>
                <div class="default-padding">
                    <button
                            data-bind="click: onChangeEndorsement" type="button" class="btn btn-warning">
                        Change endorsement
                    </button>
                </div>
            </card-body>
            <div data-template-id="ko_grid_pagination"></div>
        </card-primary>
    </script>

    <script type="text/template" id="member_ko_grid_filter_choices">
        <li data-bind="grid_filter">
            <nav class="navbar navbar-default">
                <div class="container-fluid">
                    <div class="navbar-header"><a class="navbar-brand" href="##" data-bind="text: name"></a></div>
                    <ul class="nav navbar-nav">
                        <!-- ko foreach: {data: choices, as: 'filterChoice'} -->
                        <li data-bind="css: {active: is_active()}">
                            <a data-bind="css: {bold: is_active()}, text: name, grid_filter_choice, click: onLoadFilter.bind(filterChoice)" name="#"></a>
                        </li>
                        <!-- /ko -->
                    </ul>
                </div>
            </nav>
        </li>
    </script>

{% endblock bottom_scripts %}

See member_grid_tabs.htm, member-grid.js, club_app.views_ajax for the complete example.

It’s also possible to use different layout for the different cells of datatable row via custom ko_grid_table template. Use val() method of grid row to access raw data values (eg. html attributes) and grid_cell binding to render individual (non-compound) row cells:

<script type="text/template" id="agenda_ko_grid_table">
    <div class="agenda-wrapper" data-top="true">
        <div data-bind="foreach: {data: gridRows, as: 'gridRow', afterRender: afterRowRender.bind(grid)}">
            <div data-bind="grid_row">
                <div class="agenda-image">
                    <a data-bind="attr: {href: gridRow.val('document').href}" class="link-preview" target="_blank" data-tip-css='{"z-index": 2000}'>
                        <img data-bind="attr: {src: gridRow.val('document').icon, alt: gridRow.val('document').text}" class="agenda-image">
                    </a>
                </div>
                <div class="agenda-description">
                    <span data-bind="grid_cell: 'upload_date'"></span> /
                    <span data-bind="grid_cell: 'is_latest'"></span>
                </div>
            </div>
        </div>
        <div class="jumbotron default-padding" data-bind="visible: gridRows().length === 0">
            <div data-template-id="ko_grid_no_results"></div>
        </div>
    </div>
</script>

Where document.href / document.text display values (str_fields) are generated at server-side in AgendaGrid Python class get_row_str_fields() method:

class AgendaGrid(KoGridView):

    model = AgendaFileRevision
    enable_switch_highlight = False
    grid_fields = [
        'document',
        'upload_date',
        'is_latest',
    ]
    allowed_sort_orders = [
        'upload_date',
    ]
    allowed_filter_fields = OrderedDict([
        ('upload_date', None),
        ('is_latest', None),
    ])

    def get_row_str_fields(self, obj, row=None):
        str_fields = super().get_row_str_fields(obj, row)
        str_fields['document'] = {
            'href': obj.document.url,
            'text': obj.file.basename
        }
        return str_fields

Action routing

Datatables (grids) support arbitrary number of built-in and custom actions besides standard CRUD. Thus grid requests do not use HTTP method routing such as PUT DELETE, which would be too limiting approach. All of grid actions are performed as HTTP POST; Django class-based view kwarg action value automatically generated by UrlPath is used to determine the current action:

from django_jinja_knockout.urls import UrlPath
from my_app.views import Model1Grid

# ... skipped ...
UrlPath(Model1Grid)(
    name='model1_grid',
    kwargs={'permission_required': 'my_app.change_model1'}
),
# ... skipped ...

Value of action kwarg is normalized (leading ‘/’ are stripped) and is stored in self.current_action_name property of grid class instance at server-side. Key name of view kwargs dict used for grid action url name may be changed via Django grid class static property action_kwarg:

from django_jinja_knockout.views import KoGridView
from .models import Model1

class Model1Grid(KoGridView):

    action_kwarg = 'action'
    model = Model1
    # ... skipped ...

Server-side action routing

Django class-based view derived from views.KoGridView defines the list of available actions via get_actions() method. Defined actions are implemented via grid action_NAME method, where NAME is actual name of defined action, for example built-in action 'list' is mapped to GridActionsMixin.action_list() method.

Django grid action method is called via AJAX so it is supposed to return one or more viewmodels via AJAX response, see Client-side viewmodels and AJAX response routing.

It might be either one of pre-defined viewmodels, like {'view': 'alert'} (see ioc.js for the basic list of viewmodels), or a grid viewmodel, which is routed to GridActions class (or it’s child class) at client-side. Here is the example of action implementation:

from django_jinja_knockout.views import KoGridView
# ... skipped ...

class MemberGridCustomActions(KoGridView):

    # ... skipped ...
    def action_edit_note(self):
        member = self.get_object_for_action()
        note = self.request_get('note')
        modified_members = []
        if member.note != note:
            member.note = note
            member.save()
            modified_members.append(member)
        if len(modified_members) == 0:
            return vm_list({
                'view': 'alert',
                'title': str(member.profile),
                'message': 'Note was not changed.'
            })
        else:
            return vm_list({
                'view': self.__class__.viewmodel_name,
                'update_rows': self.postprocess_qs(modified_members),
            })

views module has many built-in actions implemented, while club_app.views_ajax has some examples of custom actions code.

Client-side action routing

GridActions class is used both to invoke grid actions and to process their results.

GridActions class uses Actions as the base class for client-side viewmodel routing.

See AJAX actions for general introduction.

Invocation of action

Actions are invoked via Javascript Actions.perform() method:

Actions.perform = function(action, actionOptions, ajaxCallback)
  • 'action' argument: mandatory name of action as it is returned by Django grid get_actions() method;
  • 'actionOptions' argument: optional, custom parameters of action (usually Javascript object). These are passed to AJAX query request data. To add queryargs to some action, implement queryargs_NAME method, where NAME is actual name of action.
  • 'ajaxCallback' argument: optional function closure that will be executed when action is complete;

Interactive actions (action types 'button' / 'iconui') are also represented by instances of KoGridAction Javascript class, which is used to setup CSS classes of bound DOM element button or iconui in ko_grid_body.htm.

When bound DOM element is clicked, these interactive actions invoke Action.doAction() method for particular visual action Knockout.js viewmodel, which calls chain of Grid / GridActions methods, finally issuing the same Actions.perform() method:

Actions.doAction = function(options, actionOptions)
  • 'options' argument of object type may pass key 'gridRow' which value is the instance of GridRow class that will be used as interactive action target row. It is used by interactive actions that are related to specified grid row, such as ‘edit_form’ action. Target row instance of GridRow will be stored in Grid instance lastClickedKoRow property, accessible in GridActions derived instance this.grid.lastClickedKoRow property in every perform_NAME method, eg.:

    Model1GridActions.perform_my_action = function(queryArgs, ajaxCallback) {
        // Get raw value of last clicked grid row 'role' field.
        this.grid.lastClickedKoRow.getValue('role');
    };
    

Javascript invocation of interactive action with specified target grid row when grid just loaded first time:

Model1Grid.onFirstLoad = function() {
    // Get instance of Action for specified action name:
    var editFormAction = this.getKoAction('edit_form');
    // Find row with pk value === 3, if any, in current page queryset:
    var targetKoRow = this.findKoRowByPkVal(3);
    // Check whether the row with pk value === 3 is in current page queryset:
    if (targetKoRow !== null) {
      // Execute 'edit_form' action for row with pk value === 3.
        editFormAction.doAction({gridRow: targetKoRow});
    }
};
  • 'actionOptions' argument: optional Javascript object that is passed to Actions.perform() as actionOptions argument, with the following optional keys:
    • queryArgs: extended action AJAX POST request arguments
    • ajaxIndicator: boolean, when the value is true, enables action target AJAX request ladda progress indicator (since v2.0)
    • custom keys may be used to pass data to alter the logic of the custom client-side actions

Grid class .performAction() method is used to invoke the datatable action:

Grid.performAction = function(actionName, actionType, actionOptions)

To bind the action invocation to datatable template button:

<button class="btn-choice btn-info club-edit-grid" data-bind="click: function() { this.performAction('create_inline'); }">
    <span class="iconui iconui-plus"></span> Add row
</button>

Action queryargs

Here is the example of 'list' action AJAX request queryargs population:

GridActions.queryargs_list = function(options) {
    return this.grid.getListQueryArgs();
};

// ... skipped ...

Grid.getListQueryArgs = function() {
    this.queryArgs['list_search'] = this.gridSearchStr();
    this.queryArgs['list_filter'] = JSON.stringify(this.queryFilters);
    return this.queryArgs;
};

// ... skipped ...

Grid.listAction = function(callback) {
    this.actions.perform('list', {}, callback);
};

// ... skipped ...

Grid.searchSubstring = function(s) {
    if (typeof s !== 'undefined') {
        this.gridSearchStr(s);
    }
    this.queryArgs.page = 1;
    this.listAction();
};

Note that some keys of queryArgs object are populated in grid class own methods, while only the 'list_search' and 'list_filter' entries are set by GridActions.queryargs_list() method. It’s easier and more convenient to implement queryargs_NAME method for that purpose.

For the reverse url of Model1Grid class-based view action 'list':

http://127.0.0.1:8000/model1-grid/list/

it will generate AJAX request queryargs similar to these:

page: 2
list_search: test
list_filter: {"role": 2}
csrfmiddlewaretoken: JqkaCTUzwpl7katgKiKnYCjcMpNYfjQc

which will be parsed by KoGridView derived instance action_list() method.

it is also possible to execute actions interactively with custom options, including custom queryArgs:

Model1Grid.onFirstLoad = function() {
    var myAction = this.getKoAction('my_custom_action');
    var targetKoRow = this.findKoRowByPkVal(10);
    myAction.doAction({
        myKoProp: 123,
        queryArgs: {rowId: targetKoRow.getPkVal()},
    });
};

When action is a purely client-side one implemented via GridActions derived instance perform_NAME() method, actionOptions may be used as client-side options, for example to pass initial values of Knockout.js custom template viewmodel properties.

Action AJAX response handler

To process AJAX response data returned from Django grid action_NAME() method, one has to implement GridActions derived class, where callback_NAME() method will be used to update client-side of grid. For example, AJAX ModelForm, generated by standard ‘create_form’ action is displayed with:

import { ModelFormDialog } from '../../djk/js/modelform.js';

GridActions.callback_create_form = function(viewModel) {
    viewModel.grid = this.grid;
    var dialog = new ModelFormDialog(viewModel);
    dialog.show();
};

grid meta-data (verbose names, field filters) are updated via:

GridActions.callback_meta = function(data) {
    if (typeof data.action_kwarg !== 'undefined') {
        this.setActionKwarg(data.action_kwarg);
    }
    this.grid.loadMetaCallback(data);
};

See standard callback_*() methods in GridActions class code and custom callback_*() methods in member-grid.js for more examples.

Client-side actions

It is also possible to perform actions partially or entirely at client-side. To implement this, one should define perform_NAME() method of GridActions derived class. It’s used to display client-side BootstrapDialogs via ActionTemplateDialog -derived instances with underscore.js / knockout.js templates bound to current Grid derived instance:

import { inherit } from '../../djk/js/dash.js';
import { ActionTemplateDialog } from '../../djk/js/modelform.js';
import { Grid } from '../../djk/js/grid.js';
import { GridActions } from '../../djk/js/grid/actions.js';

MemberGridActions = function(options) {
    inherit(GridActions.prototype, this);
    this.init(options);
};

(function(MemberGridActions) {

    // Client-side invocation of the action.
    MemberGridActions.perform_edit_note = function(queryArgs, ajaxCallback) {
        var actionDialog = new ActionTemplateDialog({
            template: 'member_note_form',
            owner: this.grid,
            meta: {
                noteLabel: 'Member note',
                note: this.grid.lastClickedKoRow.getValue('note')
            },
        });
        actionDialog.show();
    };

    MemberGridActions.callback_edit_note = function(viewModel) {
        this.grid.updatePage(viewModel);
    };

})(MemberGridActions.prototype);

MemberGrid = function(options) {
    inherit(Grid.prototype, this);
    this.init(options);
};

(function(MemberGrid) {

    MemberGrid.iocGridActions = function(options) {
        return new MemberGridActions(options);
    };

})(MemberGrid.prototype);

Where the 'member_note_form' template could be like this, based on ko_action_form template located in ko_grid_body.htm:

<script type="text/template" id="member_note_form">
    <card-default">
        <card-body>
            <form class="ajax-form" enctype="multipart/form-data" method="post" role="form" data-bind="attr: {'data-url': gridActions.getLastActionUrl()}">
                <input type="hidden" name="csrfmiddlewaretoken" data-bind="value: getCsrfToken()">
                <input type="hidden" name="pk_val" data-bind="value: getLastPkVal()">
                <div class="row form-group">
                    <label data-bind="text: meta.noteLabel" class="control-label col-md-4" for="id_note"></label>
                    <div class="field col-md-6">
                        <textarea data-bind="textInput: meta.note" id="id_note" class="form-control autogrow" name="note" type="text"></textarea>
                    </div>
                </div>
            </form>
        </card-body>
    </card-default>
</script>

which may include any custom Knockout.js properties / observables bound to current grid instance. That allows to produce interactive client-side forms without extra AJAX requests.

See club_app.views_ajax, member_grid_custom_actions.htm and member-grid.js for full example of ‘edit_note’ action implementation.

Custom view kwargs

In some cases a grid may require additional kwargs to alter base queryset of grid. For example, if Django app has Member model related as many to one to Club model, grid that displays members of specified club id (foreign key value) requires additional club_id view kwarg in urls.py:

# ... skipped ...
UrlPath(ClubMemberGrid)(
    name='club_member_grid',
    # Note that 'action' arg will be appended automatically,
    # thus we have not specified it.
    # However one may specify it to re-order capture patterns:
    # args=['action', 'club_id'],
    args=['club_id],
    kwargs={'permission_required': 'my_app.change_member'}
),
# ... skipped ...

Then, grid class may filter base queryset according to received club_id view kwargs value:

class ClubMemberGrid(KoGridView):

    model = Member
    # ... skipped ...
    def get_base_queryset(self):
        return super().get_base_queryset().filter(club_id=self.kwargs['club_id'])

The component template should provide the options with specified view kwargs values. One have to pass proper initial pageRouteKwargs club_id key / value when rendering the template:

{{ ko_grid(
    grid_options={
        'pageRoute': 'club_member_grid',
        'pageRouteKwargs': {'club_id': club_id},
    },
    dom_attrs={
        'id': 'club_member_grid'
    }
) }}

This way grid will have custom list of club members according to club_id view kwarg value.

Because foreign key widgets utilize views.KoGridView and Grid classes, base querysets of foreign key widgets may be filtered as well:

class Model1Grid(KoGridView):

    allowed_filter_fields = OrderedDict([
        # Autodetect filter type.
        ('field_1', None),
        ('model2_fk', {
            # optional classPath
            # 'classPath': 'Model2Grid',
            'pageRoute': 'model2_fk_grid',
            'pageRouteKwargs': {'type': 'custom'},
            'searchPlaceholder': 'Search for Model2 values',
        }),
    ])

Standard actions

Datatables (grids) views.KoGridView are based on generic views.ActionsView class which allows to interact with any client-side AJAX component. See AJAX actions for more info.

By default views.KoGridView and GridActions offer many actions that can be applied either to the whole grid or to one / few columns of grid. Actions can be interactive (represented as UI elements) and non-interactive. Actions can be executed as one or multiple AJAX requests or be partially / purely client-side.

views.ActionsView / views.GridActionsMixin .get_actions() method returns dict defining built-in actions available. Top level of that dict is current action type.

Action definitions do not require to have 'enabled': True to be set explicitly. The action is considered to be enabled by default. That shortens the list of action definitions. To conditionally disable action, set enabled key of action definition dict to False value. See built-in .get_actions() method for the example.

Let’s see which action types are available and their associated actions.

Action type ‘built_in’

Actions that are supposed to be used internally without generation of associated invocation elements (buttons, iconui-s).

‘meta’ action

Returns AJAX response data:

  • the list of allowed sort orders for grid fields ('sortOrders');
  • flag whether search field should be displayed ('meta.hasSearch');
  • verbose name of associated Django model ('meta.verboseName' / 'meta.verboseNamePlural');
  • verbose names of associated Django model fields and related models verbose field names, see Nested verbose field names ('meta.listOptions' / 'meta.fkNestedListOptions');
  • name of primary key field 'meta.pkField' that is used in different parts of Grid to address grid rows;
  • list of defined grid actions, See Standard actions, Action routing, Custom action types;
  • allowed grid fields (list of grid columns), see Grid configuration;
  • field filters which will be displayed in top navigation bar of grid client-side component via 'ko_grid_nav' underscore.js template, see Filter fields;

Custom Django grid class-based views derived from KoGridView may return extra meta properties for custom client-side templates. These will be updated “on the fly” automatically with standard client-side GridActions class callback_meta() method.

Custom actions also can update grid meta by calling client-side Grid class updateMeta() method directly:

Model1GridActions.callback_approve_user = function(viewModel) {
    this.grid.updateMeta(viewModel.meta);
    // Do something more...
};

See Action AJAX response handler how meta is updated in client-side AJAX callback.

See Modifying visual layout of grid how to override client-side underscore.js / Knockout.js templates.

‘list’ action

Returns AJAX response data with the list of currently paginated grid rows, both “raw” database field values list and their optional str_fields formatted list counterparts. While some grids datatables may do not use str_fields at all, complex formatting of local date / time / financial currency Django model field values requires str_fields to be generated at server-side.

str_fields also are used for nested representation of fields (displaying foreign related models fields list in one grid cell).

str_fields are populated at server-side for each grid row via views.KoGridView class .get_row_str_fields() method and are converted to client-side display values in GridRow class display() method.

Both methods can be overridden in ancestor classes to customize field values output. When associated Django model has get_str_fields() method defined, it will be used to get str_fields for each row by default.

‘meta_list’ action

By default meta action is not performed in separate AJAX query, rather it’s combined with list action into one AJAX request via meta_list action. Such way it saves HTTP traffic and reduces server load. However, in some cases, grid filters or sorting orders has to be set up with specific choices before 'list' action is performed. That is required to load grid with initially selected field filter choices or to change default sorting.

‘meta_list’ action and custom initial field filters

If Django grid class specifies the list of initially selected field filter choices as active_choices:

class MemberGridTabs(MemberGrid):

    template_name = 'member_grid_tabs.htm'

    allowed_filter_fields = OrderedDict([
        ('profile', None),
        ('last_visit', None),
        # Next choices of 'plays' field filter will be set when grid loads.
        ('plays', {'active_choices': [Member.SPORT_BADMINTON, Member.SPORT_SQUASH]}),
        ('role', None),
        ('is_endorsed', None),
    ])

To make sure ClubMemberGrid action 'list' respects allowed_filter_fields definition of ['plays']['active_choices'] default choices values, one has to turn on client-side Grid class options.separateMeta value to true either with ko_grid() macro grid_options:

{{ ko_grid(
    grid_options={
        'pageRoute': 'club_member_grid',
        'separateMeta': True,
    },
    dom_attrs={
        'id': 'club_member_grid'
    }
) }}

by setting Django grid class grid_options dict separateMeta key value:

class ClubMemberGrid(KoGridView):

    model = ClubMember
    # ... skipped ...

    grid_options = {
        'classPath': 'ClubMemberGrid',
        'separateMeta': True,
    }

by overriding Django grid class get_grid_options() method:

class ClubMemberGrid(KoGridView):

    model = ClubMember
    # ... skipped ...

    @classmethod
    def get_grid_options(cls):
        return {
            'classPath': 'ClubMemberGrid',
            'separateMeta': True,
        }

via overloading of client-side Grid by custom class:

import { inherit } from '../../djk/js/dash.js';
import { Grid } from '../../djk/js/grid.js';

ClubMemberGrid = function(options) {
    inherit(Grid.prototype, this);
    /**
     * This grid has selected choices for query filter 'plays' by default,
     * thus it requires separate 'list' action after 'meta' action,
     * instead of joint 'meta_list' action.
     */
    options.separateMeta = true;
    this.init(options);
};

When ClubMemberGrid options.separateMeta is true, meta action will be issued first, setting 'plays' filter selected choices, then 'list' action will be performed separately, respecting these filter choices.

Without options.separateMeta, ClubMemberGrid plays filter will be visually highlighted as selected, but the first (initial) list action will incorrectly return unfiltered rows.

‘meta_list’ action and custom initial ordering

When one supplies custom initial ordering of rows that does not match default Django model ordering:

{{ ko_grid(
    grid_options={
        'pageRoute': 'club_grid_with_action_logging',
        'defaultOrderBy': {'foundation_date': '-'},
    },
    dom_attrs={
        'id': 'club_grid'
    }
) }}

Grid options.separateMeta will be enabled automatically and does not require to be explicitly passed in.

See club_app.views_ajax, club_grid_with_action_logging.htm for fully featured example.

‘meta list’ action preload

Sometimes one html page may include large number of Grid components. When loaded, it would cause large number of simultaneous AJAX requests, slowing the initial load performance and causing increased server load. One may preload the initial ‘meta_list’ action request at server-side by setting views.KoGridView grid_options dictionary attribute preload_meta_list to True:

class ClubMemberGrid(KoGridView):

    model = ClubMember
    # ... skipped ...

    grid_options = {
        'preload_meta_list': True,
    }

Server-side preloaded result of ‘meta_list’ action later will be passed to client-side datatable (grid) via ko_grid() macro preloadedMetaList option.

‘meta list’ action preload may fail in the following cases:

Thus it is disabled by default for the compatibility purposes.

‘update’ action

This action is not called directly internally but is implemented for user convenience. It performs the same ORM query as ‘list’ action, but instead of removing all existing rows and replacing them with new ones, it compares old rows and new rows, deletes non-existing rows, keeps unchanged rows intact, adding new rows while highlighting them.

This action is useful to update related grid rows after current grid performed some actions that changed related models of the related grid.

Open club-grid.js to see the example of manually executing ActionGrid ‘update’ action on the completion of ClubGrid ‘save_inline’ action and ‘delete_confirmed’ action:

(function(ClubGridActions) {

    ClubGridActions.updateActionGrid = function() {
        // Get instance of ActionGrid.
        var actionGrid = $('#action_grid').component();
        if (actionGrid !== null) {
            // Update ActionGrid.
            actionGrid.actions.perform('update');
        }
    };

    ClubGridActions.callback_save_inline = function(viewModel) {
        this._super._call('callback_save_inline', viewModel);
        this.updateActionGrid();
    };

    ClubGridActions.callback_delete_confirmed = function(viewModel) {
        this._super._call('callback_delete_confirmed', viewModel);
        this.updateActionGrid();
    };

})(ClubGridActions.prototype);

‘save_form’ action

Performs validation of AJAX submitted form previously created via ‘create_form’ action / ‘edit_form’ action, which will either create new grid row or edit existing grid row.

Each grid row represents an instance of associated Django model. Form rows are bound to specified Django ModelForm automatically, one has to set value of grid class form static property:

from django_jinja_knockout.views import KoGridView
from .models import Model1
from .forms import Model1Form

class Model1Grid(KoGridView):

    model = Model1
    form = Model1Form
    # ... skipped ...

Alternatively, one may define factory methods, which would bind different Django ModelForm classes to ‘create_form’ action and ‘edit_form’ action. That allows to have different set of bound model fields when creating and editing grid row Django models:

from django_jinja_knockout.views import KoGridView
from .models import Model1
from .forms import Model1CreateForm, Model1EditForm

class Model1Grid(KoGridView):

    model = Model1

    def get_create_form(self):
        return Model1CreateForm

    def get_edit_form(self):
        return Model1EditForm

‘save_form’ action will:

  • Display AJAX form errors in case there are ModelForm validation errors.
  • Create new model instance / add new row to grid when invoked via ‘create_form’ action.
  • Update existing model instance / grid row, when invoked via ‘edit_form’ action.

Grid.updatePage() method

To automatize grid update for AJAX submitted action, the following optional JSON properties could be set in AJAX viewmodel response:

  • 'append_rows': list of rows which should be appended to current grid page to the bottom;
  • 'prepend_rows': list of rows which should be prepended to current grid page from the top;
  • 'update_rows': list of rows that are updated, so their display needs to be refreshed;
  • 'deleted_pks': list of primary key values of Django models that were deleted in the database thus their rows have to be visually removed from current grid page;

Standard grid action handlers (as well as custom action handlers) may return AJAX viewmodel responses with these JSON keys to client-side action viewmodel response handler, issuing multiple CRUD operations at once. For example GridActions class callback_save_form() method:

GridActions.callback_save_form = function(viewModel) {
    this.grid.updatePage(viewModel);
};

See also views.ModelFormActionsView class action_save_form() and views.GridActionsMixin class action_delete_confirmed() methods for server-side part example.

Client-side part of multiple CRUD operation is implemented in grid.js Grid class updatePage() method.

'update_rows' response processing internally uses GridRow class .matchesPk() method to check whether two grid rows match the same Django model instance, instead of direct pkVal comparison.

It is possible to override .matchesPk() method in child class for custom grid rows matching - for example in grids datatables with RAW query LEFT JOIN which may have multiple rows with the same pkVal == null, while being distinguished by another field values.

‘save_inline’ action

Similar to ‘save_form’ action described above, this action is an AJAX form submit handler for ‘create_inline’ action / ‘edit_inline’ action. These actions generate BootstrapDialog with FormWithInlineFormsets AJAX submittable form instance bound to current grid row via views.KoGridView class form_with_inline_formsets static property:

from django_jinja_knockout.views import KoGridView
from .models import Model1
from .forms import Model1FormWithInlineFormsets

class Model1Grid(KoGridView):

    model = Model1
    form_with_inline_formsets = Model1FormWithInlineFormsets
    # ... skipped ...

Alternatively, one may define factory methods, which allows to bind different FormWithInlineFormsets classes to ‘create_inline’ action / ‘edit_inline’ action target grid row (Django model):

from django_jinja_knockout.views import KoGridView
from .models import Model1
from .forms import Model1CreateFormWithInlineFormsets, Model1EditFormWithInlineFormsets

class Model1Grid(KoGridView):

    model = Model1

    def get_create_form_with_inline_formsets(self):
        return Model1CreateFormWithInlineFormsets

    def get_edit_form_with_inline_formsets(self):
        return Model1EditFormWithInlineFormsets

These methods should return classes derived from django_jinja_knockout.forms.FormWithInlineFormsets class (see Forms).

‘delete_confirmed’ action

Deletes one or more grid rows via their pk values previously submitted by ‘delete’ action. To selectively disable deletion of some grid rows, one may implement custom action_delete_is_allowed method in the Django grid class:

class MemberGridTabs(MemberGrid):

    template_name = 'member_grid_tabs.htm'
    enable_deletion = True

    allowed_filter_fields = OrderedDict([
        ('profile', None),
        ('last_visit', None),
        # Next choices of 'plays' field filter will be set when grid loads.
        ('plays', {'active_choices': [Member.SPORT_BADMINTON, Member.SPORT_SQUASH]}),
        ('role', None),
        ('is_endorsed', None),
    ])

    # Do not allow to delete Member instances with role=Member.ROLE_FOUNDER:
    def action_delete_is_allowed(self, objects):
        # ._clone() is required because original pagination queryset is passed as objects argument.
        qs = objects._clone()
        return not qs.filter(role=Member.ROLE_FOUNDER).exists()

See club_app.views_ajax for full-featured example.

Action type ‘button’

These actions are visually displayed as buttons and manually invoked via button click. With the default underscore.js templates these buttons are located at top navbar of the grid (datatable). Usually type 'button' actions are not targeted to the single row, but are supposed either to create new rows or to process the whole queryset / list of rows.

However, when Grid -derived class instance has visible row selection enabled via init() method options.showSelection = true and / or options.selectMultipleRows = true, the button action could be applied to the selected row(s) as well.

New actions of button type may be added by overriding .get_actions() method of views.KoGridView derived class, then extending client-side GridActions class to implement custom 'callback_' method (see Client-side actions for more info).

‘create_form’ action

Server-side part of this action renders AJAX-powered Django ModelForm instance bound to new Django grid model.

Client-side part of this action displays rendered ModelForm as BootstrapDialog modal dialog. Together with ‘save_form’ action, which serves as callback for this action, it allows to create new grid rows (new Django model instances).

This action is enabled (and thus UI button will be displayed in grid component navbar) when Django grid class-based view has assigned ModelForm class specified as:

from django_jinja_knockout.views import KoGridView
from .models import Model1
from .forms import Model1Form

class Model1Grid(KoGridView):

    model = Model1
    form = Model1Form
    # ... skipped ...

Alternatively, one may define factory methods, which would bind different Django ModelForm classes to ‘create_form’ action and ‘edit_form’ action. That allows to have different set of bound model fields when creating and editing grid row Django models:

from django_jinja_knockout.views import KoGridView
from .models import Model1
from .forms import Model1CreateForm, Model1EditForm

class Model1Grid(KoGridView):

    model = Model1

    def get_create_form(self):
        return Model1CreateForm

    def get_edit_form(self):
        return Model1EditForm

When one would look at server-side part of views.GridActionsMixin class action_create_form() method source code, there is last_action viewmodel key with value 'save_form' returned to Javascript client-side:

# ... skipped ...
return vm_list({
    'view': self.__class__.viewmodel_name,
    'last_action': 'save_form',
    'title': format_html('{}: {}',
        self.get_action_local_name(),
        self.get_model_meta('verbose_name')
    ),
    'message': form_html
})

Viewmodel action overrides

Viewmodel’s last_action optional key is used in client-side Javascript GridActions class respond() method to override the name of last executed action from current 'create_form' to 'save_form'. The name of last executed action is used to generate last action url value in grid templates (ko_grid_body.htm) / component templates via Actions class getLastActionUrl() method.

It is then used in client-side Javascript ModelFormDialog class getButtons() method submit button event handler to perform ‘save_form’ action when that button is clicked by end-user, instead of already executed ‘create_form’ action, which already generated AJAX model form and displayed it using ModelFormDialog instance.

Viewmodel’s callback_action optional key is used in client-side Javascript to override the action callback method. Some viewmodel callbacks may share the same action callback method (handler) to reduce duplication of code. Since v2.0 views.ModelFormActionsView .vm_form() also supports optional specifying ot callback_action value.

See Action AJAX response handler for more info on action client-side AJAX callbacks.

‘create_inline’ action

Server-side part of this action renders AJAX-powered forms.FormWithInlineFormsets instance bound to new Django grid model.

Client-side part of this action displays rendered FormWithInlineFormsets as BootstrapDialog modal. Together with ‘save_inline’ action, which serves as callback for this action, it allows to create new grid rows (new Django model instances) while also adding one to many related models instances via one or multiple inline formsets.

This action is enabled (and thus UI button will be displayed in grid component navbar) when Django grid class-based view has assigned forms.FormWithInlineFormsets derived class (see Forms for more info about that class). It should be specified as:

from django_jinja_knockout.views import KoGridView
from .models import Model1
from .forms import Model1FormWithInlineFormsets

class Model1Grid(KoGridView):

    model = Model1
    form_with_inline_formsets = Model1FormWithInlineFormsets
    # ... skipped ...

Alternatively, one may define factory methods, which allows to bind different FormWithInlineFormsets derived classes to ‘create_inline’ action new row and ‘edit_inline’ action existing grid row (Django model):

from django_jinja_knockout.views import KoGridView
from .models import Model1
from .forms import Model1CreateFormWithInlineFormsets, Model1EditFormWithInlineFormsets

class Model1Grid(KoGridView):

    model = Model1

    def get_create_form_with_inline_formsets(self):
        return Model1CreateFormWithInlineFormsets

    def get_edit_form_with_inline_formsets(self):
        return Model1EditFormWithInlineFormsets
  • Server-side part of this action overrides the name of last executed action by setting AJAX response viewmodel last_action key to save_inline value, which specifies the action of BootstrapDialog form modal button. See ‘create_form’ action description for more info about last_action key.
  • views.KoGridInline class is the same views.KoGridView class only using different value of template_name class property pointing to Jinja2 template which includes formsets.js by default.
  • See club_app.views_ajax for fully featured example of KoGridView form_with_inline_formsets usage.

Action type ‘click’

These actions are designed to process already displayed grid row, associated to existing Django model.

  • By default there is no active click actions, so clicking grid row does nothing.
  • When there is only one click action enabled, it will be executed immediately after end-user clicking of target row.
  • When there is more than one click actions enabled, Grid will use special version of BootstrapDialog wrapper ActionsMenuDialog to display menu with clickable buttons to select one action from the list of available ones.

Cell actions

Since v2.0, click type of action optionally supports specifying Grid row cells as action target, which makes possible to define separate click actions for each / multiple cells of Grid row:

from django_jinja_knockout.views import KoGridView

class PriceGrid(KoGridView):

grid_fields = [
    [
        'step_price_percent',
        'min_growth_percent',
        'max_growth_percent',
    ],
    [
        'decline_threshold',
        'growth_threshold',
    ]
]

def get_actions(self):
    actions = super().get_actions()
    nested_update(actions, {
        'click': {
            'edit_price_percent_form': {
                'localName': 'Edit price percentage change',
                'css': 'btn-default',
                'cells': [
                    'step_price_percent',
                    'min_growth_percent',
                    'max_growth_percent',
                ],
            },
            'edit_threshold_form': {
                'localName': 'Edit price threshold',
                'css': 'btn-default',
                'cells': [
                    'decline_threshold',
                    'growth_threshold',
                ],
            },
        }
    })
    return actions

Such way, edit_price_percent_form and edit_threshold_form actions will be performed only for the selected grid_fields cells names.

See Grid.getCellActions() method for the details of the client-side cell actions implementation.

‘edit_form’ action

This action is enabled when current Django grid class inherited from views.KoGridView class has class property form set to specified Django ModelForm class used to edit grid row via associated Django model:

from django_jinja_knockout.views import KoGridView
from .models import Model1
from .forms import Model1Form

class Model1Grid(KoGridView):

    model = Model1
    form = Model1Form

Alternatively, one may define get_edit_form() Django grid method to return ModelForm class dynamically.

Server-side of this action is implemented via views.GridActionsMixin class action_edit_form() method. It returns AJAX response with generated HTML of ModelForm instance bound to target grid row Django model instance. Returned viewmodel last_action property value is set to 'save_form', to override GridActions class lastActionName property.

Client-side of this action uses ModelFormDialog to display generated ModelForm html and to submit AJAX form to ‘save_form’ action.

‘edit_inline’ action

This action is enabled when current Django grid class has defined class property form_with_inline_formsets set to forms.FormWithInlineFormsets derived class used to edit grid row and it’s foreign relationships via Django inline formsets (see Forms):

from django_jinja_knockout.views import KoGridView
from .models import Model1
from .forms import Model1FormWithInlineFormsets

class Model1Grid(KoGridView):

    model = Model1
    form_with_inline_formsets = Model1FormWithInlineFormsets

Alternatively, one may define get_edit_form_with_inline_formsets() Django grid method to return FormWithInlineFormsets derived class dynamically.

Server-side of this action is implemented in views.GridActionsMixin class action_edit_inline() method. It returns AJAX response with generated HTML of FormWithInlineFormsets instance bound to target grid row Django model instance. Returned viewmodel last_action property value is set to 'save_inline', to override GridActions class lastActionName property.

Client-side of this action uses ModelFormDialog to display generated FormWithInlineFormsets html and to submit AJAX form to ‘save_inline’ action.

See Implementing custom grid row actions section how to implement custom actions of 'click' and 'iconui' types.

Action type ‘pagination’

Actions of pagination type adds iconui buttons directly to pagination control of the current grid (datatable). These actions may be applied to the whole grid or to the selected grid rows, similarly to Action type ‘button’.

The following built-in actions of this type are implemented:

‘rows_per_page’ action

Allows to select the number of rows per grid (datatable) page via Bootstrap dialog. This may be useful when one wants to observe more rows or to select more rows to perform subsequent mass-rows actions. When number of displayed rows is changed, it tries to keep the current top row visible.

By default it allows to chose 1x to 5x steps from the current OBJECTS_PER_PAGE. It can be overridden in child class by changing default 'range' settings of action definition:

from django_jinja_knockout.views import KoGridView
from django_jinja_knockout.utils.sdv import nested_update
from my_app.models import Member
# ... skipped ...

class MemberGrid(KoGridView):

    model = Member
    # ... skipped ...

    def get_actions(self):
        actions = super().get_actions()
        nested_update(actions, {
            'pagination': {
                'rows_per_page': {
                    'range': {
                        'min': 10,
                        'max': 100,
                        'step': 10,
                    },
                },
            }
        })
        return actions

Be aware that enabling large 'rows_per_page' value may greatly increase server load. For high-load sites this action could be conditionally disabled (eg. for anonymous users), by setting key 'enabled' to False, such as for every another action out there.

‘switch_highlight’ action

Cycles between the defined highlight modes of grid. The following built-in highlight modes are available:

'none', 'cycleColumns', 'cycleRows', 'linearRows'

Default highlight mode is set via overriding current grid (datatable) like this:

class MemberGrid(KoGridView):

    grid_options = {
        'highlightMode': 'cycleColumns',
    }

    # or, like this:
    @classmethod
    def get_grid_options(cls):
        grid_options = super().get_grid_options()
        grid_options['highlightMode'] = 'cycleColumns'
        return grid_options

It is possible to disable some of highlight modes or to define new ones via Client-side class overriding and providing custom list of highlightModeRules values in overridden (inherited) grid (datatable) class.

Traditional (non-AJAX) request views.list.ListSortingView also supports highlight_mode attribute with similar highlighting settings, but no dynamical change of current highlight mode.

Action type ‘button_pagination’

Actions of button_pagination type are similar to Action type ‘button’ but they are displayed in the grid footer together with Action type ‘pagination’ controls.

There are no built-in actions of this type, they are implemented for end-user convenience.

Action type ‘iconui’

These actions are designed to process already displayed grid (datatable) row, associated to existing Django model. Their implementation is very similar to Action type ‘button’, but instead of clicking at any place of row, these actions are visually displayed as iconui links in separate columns of grid.

Visually, iconui uses the following fonts for each of the supported Bootstrap version:

iconui actions are rendered in the single column of datatable, instead of each action per column for better utilization of the display space.

By default there is no iconui type actions enabled. But there is one standard action of such type implemented for KoGridView: ‘delete’ action.

‘delete’ action

This action deletes grid row (Django model instance) but is disabled by default. To enable grid row deletion, one has to set Django grid class property enable_deletion value to True:

from django_jinja_knockout.views import KoGridView
from .models import Manufacturer
from .forms import ManufacturerForm

class ManufacturerGrid(KoGridView):

    model = Manufacturer
    form = ManufacturerForm
    enable_deletion = True
    grid_fields = '__all__'
    allowed_sort_orders = '__all__'
    allowed_filter_fields = OrderedDict([
        ('direct_shipping', None)
    ])
    search_fields = [
        ('company_name', 'icontains'),
    ]

This grid also specifies form class property, which enables all CRUD operations with Manufacturer Django model.

Note that ‘delete_confirmed’ action is used as success callback for ‘delete’ action and usually both are enabled or disabled per grid class - if one considers to check the user permissions:

from django_jinja_knockout.views import KoGridView
from .models import Manufacturer
from .forms import ManufacturerForm

class ManufacturerGrid(KoGridView):

    model = Manufacturer
    form = ManufacturerForm

    # General built-in 'delete' / 'delete_confirmed' rights.
    def get_enable_deletion(self):
        return self.request.user.has_perm('club_app.delete_manufacturer')

    # Not required, just an example of setting custom rights
    def get_actions(self):
        actions = super().get_actions()
        # Only enable to create new rows when the deletion is enabled.
        actions['button']['create_form']['enabled'] = actions['built_in']['delete_confirmed']['enabled']
        return actions

views.KoGridView has built-in support permission checking of deletion rights for selected rows lists / querysets. See ‘delete_confirmed’ action for the primer of checking delete permissions per row / queryset.

The action itself is defined in views.GridActionsMixin class:

enable_deletion = self.get_enable_deletion()
return {
    'built_in': OrderedDict([
        ('delete_confirmed', {
            'enabled': enable_deletion
        })
    ]),
    'iconui': OrderedDict([
        # Delete one or many model object.
        ('delete', {
            'localName': _('Remove'),
            'css': 'iconui-remove',
            'enabled': enable_deletion
        })
    ]),
}

See Implementing custom grid row actions section how to implement custom actions of 'click' and 'iconui' types.

Imagine one grid having custom iconui action defined like this:

class MemberGrid(KoGridView):
    model = Member
    form = MemberFormForGrid

    def get_actions(self):
        actions = super().get_actions()
        actions['iconui']['quick_endorse'] = {
            'localName': _('Quick endorsement'),
            'css': 'iconui-cloud-upload',
        }
        return actions

Grid rows may selectively enable / disable their actions on the fly with visual updates. It is especially important to actions of type 'iconui', because these are always visible in grid columns.

To implement online visibility update of grid row actions one should override client-side GridRow class hasEnabledAction() method like this:

import { inherit } from '../../djk/js/dash.js';
import { Grid } from '../../djk/js/grid.js';
import { GridRow } from '../../djk/js/grid/row.js';

MemberGridRow = function(options) {
    inherit(GridRow.prototype, this);
    this.init(options);
};

(function(MemberGridRow) {

    // .. skipped ...

    MemberGridRow.hasEnabledAction = function(action) {
        if (action.name === 'quick_endorse' && this.values['is_endorsed'] === true) {
            return false;
        }
        return true;
    };

})(MemberGridRow.prototype);

MemberGrid = function(options) {
    inherit(Grid.prototype, this);
    this.init(options);
};

(function(MemberGrid) {

    // .. skipped ...

    MemberGrid.iocRow = function(options) {
        return new MemberGridRow(options);
    };

})(MemberGrid.prototype);

This way action of iconui type with 'quick_endorse' name will be displayed as link only when associated Django model instance field name is_endorsed has the value true. Otherwise the link to action will be hidden. Updating grid rows via Grid class updatePage() method will cause visual re-draw of available grid rows actions display.

In case action is not purely client-side (has callback_NAME), additional permission check should also be performed with server-side Django grid action_NAME method.

Implementing custom grid row actions

First step to add new action is to override get_actions() method in Django grid class. Let’s create new action 'ask_user' of 'click' type:

from django_jinja_knockout.views import KoGridView
from .models import Profile
from django.utils.translation import ugettext as _

class ProfileGrid(KoGridView):

    model = Profile
    # ... skipped ...

    def get_actions(self):
        actions = super().get_actions()
        action_type = 'click'
        actions[action_type]['ask_user'] = {
            'localName': _('Ask user'),
            'css': 'btn-warning',
        }
        return actions

To create new action 'ask_user' of 'iconui' type instead:

from django_jinja_knockout.views import KoGridView
from .models import Profile
from django.utils.translation import ugettext as _

class ProfileGrid(KoGridView):

    model = Profile
    # ... skipped ...

    def get_actions(self):
        actions = super().get_actions()
        action_type = 'iconui'
        actions[action_type]['ask_user'] = {
            'localName': _('Ask user'),
            'css': 'iconui-user',
        }
        return actions

Next step is to implement newly defined action server-side and / or it’s client-side parts.

If one wants to bind multiple different Django ModelForm edit actions to grid, the server-side of the custom action might be implemented like this:

from django_jinja_knockout.views import KoGridView
from .models import Profile
from .forms import ProfileForm, ProfileAskUserForm

class ProfileGrid(KoGridView):

    model = Profile
    # This form will be used for actions 'create_form' / 'edit_form' / 'save_form'.
    form = ProfileForm
    # ... skipped ...

    # Based on GridActionsMixin.action_edit_form() implementation.
    def action_ask_user(self):
        # Works with single row, thus we are getting single model instance.
        obj = self.get_object_for_action()
        # This form will be used for actions 'ask_user' / 'save_form'.
        form = ProfileAskUserForm(instance=obj)
        return self.vm_form(
            form, self.render_object_desc(obj), {'pk_val': obj.pk}
        )
  • Actions which work with single objects (single row) should use get_object_for_action() method to obtain Django model object instance of target grid row.
  • Actions which work with lists / querysets of objects (multiple rows) should use get_queryset_for_action() method to obtain the whole queryset of selected grid rows. See action_delete() / action_delete_confirmed() methods code in views.GridActionsMixin class for example.

ModelFormDialog class will be used to render AJAX-generated Django ModelForm at client-side. One has to inherit ProfileGridActions from GridActions and define custom action’s own callback_NAME:

import { ModelFormDialog } from '../../djk/js/modelform.js';
import { GridActions } from '../../djk/js/grid/actions.js';

ProfileGridActions.callback_ask_user = function(viewModel) {
    viewModel.grid = this.grid;
    var dialog = new ModelFormDialog(viewModel);
    dialog.show();
};

Completely different way of generating form with pure client-side underscore.js / Knockout.js templates for custom action (no AJAX callback is required to generate form HTML) is implemented in Client-side actions section of the documentation.

ForeignKeyGridWidget

widgets.ForeignKeyGridWidget is similar to ForeignKeyRawIdWidget implemented in django.contrib.admin.widgets, but is easier to integrate into non-admin views. It provides built-in sorting / filters and optional CRUD editing of related model rows, because it is based on the code of views.KoGridView and grid.js.

Let’s imagine we have two Django models with one to many relationships:

from django.db import models

class Profile(models.Model):
    first_name = models.CharField(max_length=30, verbose_name='First name')
    last_name = models.CharField(max_length=30, verbose_name='Last name')
    birth_date = models.DateField(db_index=True, verbose_name='Birth date')
    # ... skipped ...

class Member(models.Model):
    profile = models.ForeignKey(Profile, verbose_name='Sportsman')
    # ... skipped ...

Now we will define MemberForm bound to Member model:

from django import forms
from django_jinja_knockout.widgets import ForeignKeyGridWidget
from django_jinja_knockout.forms BootstrapModelForm
from .models import Member

class MemberForm(BootstrapModelForm):

    class Meta:
        model = Member
        fields = '__all__'
        widgets = {
            'profile': ForeignKeyGridWidget(model=Profile, grid_options={
                'pageRoute': 'profile_fk_widget',
                'dialogOptions': {'size': 'size-wide'},
                # Foreign key filter options will be auto-detected, but these could
                # have been defined explicitly when needed:
                # 'fkGridOptions': {
                #     'user': {
                #         'pageRoute': 'user_fk_widget',
                #     },
                # },
                # Override default search field label (optional):
                'searchPlaceholder': 'Search user profiles',
            }),
            'plays': forms.RadioSelect(),
            'role': forms.RadioSelect()
        }

Any valid Grid constructor option can be specified as grid_options argument of ForeignKeyGridWidget, including nested foreign key widgets and filters (see commented fkGridOptions section).

To bind MemberForm profile field widget to actual Profile model grid, we have specified class-based view url name (route) of our widget as 'pageRoute' argument value 'profile_fk_widget'.

Now to implement the class-based grid view once for any possible ModelForm with 'profile' foreign field:

from django_jinja_knockout import KoGridView
from .models import Profile

class ProfileFkWidgetGrid(KoGridView):

    model = Profile
    grid_fields = ['first_name', 'last_name']
    allowed_sort_orders = '__all__'
    search_fields = [
        ('first_name', 'icontains'),
        ('last_name', 'icontains'),
    ]

We can set ProfileFkWidgetGrid attribute form = ProfileForm, so Profile foreign key widget will support in-place CRUD AJAX actions, allowing to create new Profiles just in place before the related MemberForm instance is saved:

from django_jinja_knockout import KoGridView
from .models import Profile
from .forms import ProfileForm

class ProfileFkWidgetGrid(KoGridView):

    model = Profile
    form = ProfileForm
    enable_deletion = True
    grid_fields = ['first_name', 'last_name']
    allowed_sort_orders = '__all__'
    search_fields = [
        ('first_name', 'icontains'),
        ('last_name', 'icontains'),
    ]

and finally to define 'profile_fk_widget' url name in urls.py:

from django_jinja_knockout.urls import UrlPath
from club_app.views_ajax import ProfileFkWidgetGrid

# ... skipped ...
UrlPath(ProfileFkWidgetGrid)(
    name='profile_fk_widget',
    # kwargs={'permission_required': 'club_app.change_profile'}
),
UrlPath(UserFkWidgetGrid)(
    name='user_fk_widget',
    # kwargs={'permission_required': 'auth.change_user'}
),

Typical usage of ModelForm such as MemberForm is to perform CRUD actions in views or in grids datatables with Django model instances. In such case do not forget to inject url name of 'profile_fk_widget' to client-side for AJAX requests to work automatically.

In your class-based view that handlers MemberForm inject 'profile_fk_widget' url name (route) at client-side (see Installation and context_processors.py for details about injecting url names to client-side via client_routes):

from django.views.generic.edit import CreateView
from .forms import MemberForm

class MemberCreate(CreateView):
    # Next line is required for ``ProfileFkWidgetGrid`` to be callable from client-side:
    client_routes = {
        'profile_fk_widget'
    }
    form = MemberForm

The same widget can be used with MemberForm bound to datatables via ‘create_form’ action / ‘edit_form’ action, or with any custom action, both AJAX requests and traditional requests.

When widget is used in many different views, it could be more convenient to register client-side route (url name) globally in project’s settings.py. Such client-side routes will be injected into every generated page via base_bottom_scripts.htm:

# Second element of each tuple defines whether the client-side route should be available to anonymous users.
DJK_CLIENT_ROUTES = {
    ('equipment_grid', True),
    ('profile_fk_widget', False),
    ('user_fk_widget', False),
    ('user_change', False),
}

ForeignKeyGridWidget implementation notes

Both ForeignKeyGridWidget and MultipleKeyGridWidget inherit from base class widgets.BaseGridWidget.

Client-side part of ForeignKeyGridWidget is implemented in FkGridWidget class. It uses the instance of GridDialog class to browse and to select foreign key field value(s) for the related ModelForm.

views.KoGridView class postprocess_row() method is used to generate str() representation for each Django model instance associated to each grid row, in case there is neither Django model get_str_fields() method nor grid class custom method get_row_str_fields() defined:

def postprocess_row(self, row, obj):
    str_fields = self.get_row_str_fields(obj, row)
    if str_fields is None or self.__class__.force_str_desc:
        row['__str'] = str(obj)
    if str_fields is not None:
        row['__str_fields'] = str_fields
    return row

views.KoGridRelationView overrides postprocess_row method so the row also includes __perm key, which is then stored to GridRow instance .perm attribute to determine additional grid row permissions, such as canDelete (foreign key deletion per row) in FkGridWidget inputRow.

In case str_fields representation of row is too verbose for ForeignKeyGridWidget display value, one may define grid class property force_str_desc = True to always use str() representation instead:

class ProfileFkWidgetGrid(KoGridView):

    model = Profile
    form = ProfileForm
    enable_deletion = True
    force_str_desc = True
    grid_fields = ['first_name', 'last_name']
    allowed_sort_orders = '__all__'
    search_fields = [
        ('first_name', 'icontains'),
        ('last_name', 'icontains'),
    ]

str_fields still will be used to automatically format or localize row field values in grid, when available.

Client-side of widget is dependent either on cbv_grid.htm or cbv_grid_inline.htm Jinja2 templates, which generate grid underscore.js client-side templates via ko_grid_body() macro call.

One has to use these templates in his project, or to develop separate templates with these client-side scripts included. It’s possible to include Jinja2 templates from Django templates using custom template tags library:

{% load %jinja %}
{% jinja 'ko_grid_body.htm' with _render_=1 %}
  • See club_grid.html for example of grid templates generation in Django Template Language.

ko_grid_body() macro contains ko_fk_grid_widget / ko_fk_grid_widget_row / ko_fk_grid_widget_controls templates, used by widgets.BaseGridWidget and it’s ancestors. To customize visual layout of widget / selected foreign key rows, one may use attrs and grid_options widget kwargs with Javascript class and / or template names like this:

self.fields['tag_set'] = forms.ModelMultipleChoiceField(
    widget=MultipleKeyGridWidget(
        attrs={
            # Override widget Javascript class name (optional)
            'classPath': 'TagWidget',
            # Override widget templates
            'data-template-options': {
                'templates': {
                    'ko_fk_grid_widget_row': 'ko_tag_widget_row',
                    'ko_fk_grid_widget_controls': 'ko_tag_widget_controls',
                }
            }
        },
        grid_options={
            # Override foreign key grid Javascript class name (optional)
            'classPath': 'TagGrid',
            # Set url 'club_id` kwargs initial value (when needed)
            'pageRoute': 'tag_fk_widget',
            'pageRouteKwargs': {
                'club_id': 0 if self.instance.club is None else self.instance.club.pk,
            },
        },
    )
}

Then to define actual templates in html code:

<script type="text/template" id="ko_tag_widget_row">
    <div class="container col-auto" data-bind="css: inputRow.css, click: inputRow.onClick">
        <div class="row well well-sm default-margin">
            <div class="col-sm-6">
                <div class="badge preformatted" data-bind="text: inputRow.desc().name"></div>
            </div>
            <div class="col-sm-1">
                <a class="close" data-bind="visible: inputRow.canDelete, click: inputRow.remove">×</a>
            </div>
        </div>
    </div>
</script>

<script type="text/template" id="ko_tag_widget_controls">
<div data-top="true">
    <button class="btn btn-info default-margin" data-bind="click: onFkButtonClick, clickBubble: false">{{ _('Change') }}</button>
    <button class="btn btn-success default-margin">Custom action</button>
</div>
</script>

inputRow .desc() attribute is generated by GridRow.getDescParts() method, which uses get_str_fields(), when available and falls down to str() representation otherwise.

The value of attrs argument of ForeignKeyGridWidget defines widget DOM attrs which may optionally include the following special DOM attributes:

  • classPath - override widget component Javascript class name (FkGridWidget)
  • data-template-id - override widget html template (‘ko_fk_grid_widget’)
  • data-template-options - specify Underscore.js templates template options, eg. to override widget’s nested template names (like ko_tag_widget_row in the example above)

The value of grid_options argument of ForeignKeyGridWidget() is very much similar to the definition of 'fkGridOptions' value for Foreign key filter. Both embed datatables (grids) inside BootstrapDialog, with the following differences:

Widget’s Python code generates client-side component similar to ko_grid() macro, but it uses FkGridWidget component class instead of Grid component class.

MultipleKeyGridWidget

Since version 1.0.0, editing of many to many relations is supported via MultipleKeyGridWidget. For example, each Club has many to many relation to Tag:

class Tag(models.Model):
    name = models.CharField(max_length=32, verbose_name='Tag')
    clubs = models.ManyToManyField(Club, blank=True, verbose_name='Clubs')

    def get_str_fields(self):
        str_fields = OrderedDict([
            ('name, self.name)
        ])
        return str_fields

Then to edit multiple relations of Club in ClubForm:

from django_jinja_knockout.forms import RendererModelForm

class TagForm(RendererModelForm):

    class Meta:
        model = Tag
        fields = ['name']

class ClubForm(RendererModelForm):

    def add_tag_set_fk_grid(self):
        # https://kite.com/python/docs/django.forms.ModelMultipleChoiceField
        # value = field.widget.value_from_datadict(self.data, self.files, self.add_prefix(name))
        self.fields['tag_set'] = forms.ModelMultipleChoiceField(
            widget=MultipleKeyGridWidget(grid_options={
                'pageRoute': 'tag_fk_widget',
            }),
            queryset=Tag.objects.all(),
            required=False,
        )
        if self.instance.pk is not None:
            self.fields['tag_set'].initial = self.instance.tag_set.all()

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.add_tag_set_fk_grid()

Now define the widget grid for Tag model:

class TagFkWidgetGrid(KoGridRelationView):

    form = TagForm
    grid_fields = ['name']

it’s url (UrlPath):

from django_jinja_knockout.urls import UrlPath

UrlPath(TagFkWidgetGrid)(
    name='tag_fk_widget',
    # kwargs={'permission_required': 'club_app.change_tag'},
),

and it’s client-route:

class ClubEditMixin(ClubNavsMixin):

    client_routes = {
        'manufacturer_fk_widget',
        'profile_fk_widget',
        'tag_fk_widget',
    }
    template_name = 'club_edit.htm'
    form_with_inline_formsets = ClubFormWithInlineFormsets

The definition is very similar to ForeignKeyGridWidget with the exception that multiple keys are allowed to add / edit / delete.

KoGridRelationView selects which relation rows are allowed to remove for the current user via overriding it’s can_delete_relation() method.

Grids interaction

Multiple grid components can be rendered at single html page via multiple Jinja2 ko_grid() macro calls. Each grid will have it’s own sorting, filters, pagination and actions. Sometimes it’s desirable to update one grid state depending on the results of action performed in another grid.

Server-side interaction between grids

Let’s see ClubEquipmentGrid which allows to add instances of Equipment model to particular instance of Club model. It’s quite similar to inline formsets but saves the relation during each related form / model adding. Such way it provides a kind of “quick save” feature and also allows to edit large set of related Equipment models with pagination and optional search / filtering - not having to load the whole queryset as inline formset.

ClubEquipmentGrid has two custom actions 'add_equipment' / 'save_equipment' implemented as:

class ClubEquipmentGrid(EditableClubGrid):

    client_routes = {
        'equipment_grid',
        'club_grid_simple',
        'manufacturer_fk_widget',
    }
    template_name = 'club_equipment.htm'
    form = ClubForm
    form_with_inline_formsets = None

    def get_actions(self):
        actions = super().get_actions()
        actions['built_in']['save_equipment'] = {}
        actions['iconui']['add_equipment'] = {
            'localName': _('Add club equipment'),
            'css': 'iconui-wrench',
        }
        return actions

    # Creates AJAX ClubEquipmentForm bound to particular Club instance.
    def action_add_equipment(self):
        club = self.get_object_for_action()
        if club is None:
            return vm_list({
                'view': 'alert_error',
                'title': 'Error',
                'message': 'Unknown instance of Club'
            })
        equipment_form = ClubEquipmentForm(initial={'club': club.pk})
        # Generate equipment_form viewmodel
        vms = self.vm_form(
            equipment_form, form_action='save_equipment'
        )
        return vms

    # Validates and saves the Equipment model instance via bound ClubEquipmentForm.
    def action_save_equipment(self):
        form = ClubEquipmentForm(self.request.POST)
        if not form.is_valid():
            form_vms = vm_list()
            self.add_form_viewmodels(form, form_vms)
            return form_vms
        equipment = form.save()
        club = equipment.club
        club.last_update = timezone.now()
        club.save()
        # Instantiate related EquipmentGrid to use it's .postprocess_qs() method
        # to update it's row via grid viewmodel 'prepend_rows' key value.
        equipment_grid = EquipmentGrid()
        equipment_grid.request = self.request
        equipment_grid.init_class(equipment_grid)
        return vm_list({
            'view': self.__class__.viewmodel_name,
            'update_rows': self.postprocess_qs([club]),
            # return grid rows for client-side EquipmentGrid component .updatePage(),
            'equipment_grid_view': {
                'prepend_rows': equipment_grid.postprocess_qs([equipment])
            }
        })
  • 'add_equipment' action creates ClubEquipmentForm bound to particular Club foreign key instance.
  • 'save_equipment' action validates and saves Equipment model instance related to target row Club instance via bound ClubEquipmentForm.
  • Because both ClubEquipmentGrid and EquipmentGrid are sharing single club_equipment.htm template, ClubEquipmentGrid defines all client-side url names (client routes), required for EquipmentGrid and it’s foreign key filters to work as client_routes class property.

Note that grid viewmodel returned by ClubEquipmentGrid class action_save_equipment() method has 'equipment_grid_view' subproperty which will be used to update rows of EquipmentGrid at client-side (see below). Two lists of rows are returned to be updated via Grid.updatePage() method:

  • vm_list 'update_rows': self.postprocess_qs([club]) list of rows to be updated for ClubEquipmentGrid
  • vm_list 'equipment_grid_view': {'prepend_rows': ...} list of rows to be updated for EquipmentGrid

EquipmentGrid is much simpler because it does not define custom actions. It’s just used to display both already existing and newly added values of particular Club related Equipment model instances:

class EquipmentGrid(KoGridView):
    model = Equipment
    grid_fields = [
        'club',
        'manufacturer',
        'inventory_name',
        'category',
    ]
    search_fields = [
        ('inventory_name', 'icontains')
    ]
    allowed_filter_fields = OrderedDict([
        ('club', {
            'pageRoute': 'club_grid_simple',
            # Optional setting for BootstrapDialog:
            'dialogOptions': {'size': 'size-wide'},
        }),
        ('manufacturer', {
            'pageRoute': 'manufacturer_fk_widget'
        }),
        ('category', None)
    ])
    grid_options = {
        'searchPlaceholder': 'Search inventory name',
    }

To see full-size example:

Client-side interaction between grids

At client-side ClubEquipmentGrid is instantiated as ClubGrid via club-grid.js script. It implements custom callback_* methods via ClubGridActions class for the following actions:

  • callback_save_form / callback_save_inline / callback_delete_confirmed updates related grid(s) via looking up for their .component() then performing .perform('update') on component class, when available.
  • callback_add_equipment displays BootstrapDialog with AJAX-submittable ClubEquipmentForm via .callback_create_form(viewModel) built-in method call.
  • callback_save_equipment updates row(s) of both ClubGrid, which is bound to ClubEquipmentGrid class-based view in club_equipment.htm and EquipmentGrid, which does not define custom client-side grid class.

Here is the code of grid AJAX callbacks:

(function(ClubGridActions) {

    ClubGridActions.updateDependentGrid = function(selector) {
        // Get instance of dependent grid.
        var grid = $(selector).component();
        if (grid !== null) {
            // Update dependent grid.
            grid.actions.perform('update');
        }
    };

    // Used in club_app.views_ajax.ClubGridWithActionLogging.
    ClubGridActions.callback_save_inline = function(viewModel) {
        this._super._call('callback_save_form', viewModel);
        this.updateDependentGrid('#action_grid');
        this.updateDependentGrid('#equipment_grid');
    };

    // Used in club_app.views_ajax.ClubEquipmentGrid.
    ClubGridActions.callback_save_form = function(viewModel) {
        this._super._call('callback_save_form', viewModel);
        this.updateDependentGrid('#action_grid');
        this.updateDependentGrid('#equipment_grid');
    };

    ClubGridActions.callback_delete_confirmed = function(viewModel) {
        this._super._call('callback_delete_confirmed', viewModel);
        this.updateDependentGrid('#action_grid');
        this.updateDependentGrid('#equipment_grid');
    };

    ClubGridActions.callback_add_equipment = function(viewModel) {
        this.callback_create_form(viewModel);
    };

    ClubGridActions.callback_save_equipment = function(viewModel) {
        var equipmentGridView = viewModel.equipment_grid_view;
        delete viewModel.equipment_grid_view;
        this.grid.updatePage(viewModel);
        // Get client-side class of EquipmentGrid component by id (instance of Grid or derived class).
        var equipmentGrid = $('#equipment_grid').component();
        if (equipmentGrid !== null) {
            // Update rows of MemberGrid component (instance of Grid or derived class).
            equipmentGrid.updatePage(equipmentGridView);
        }
    };

})(ClubGridActions.prototype);
  • callback_save_equipment uses jQuery selector $('#equipment_grid') to find root DOM element for EquipmentGrid component.
  • Because there is no custom client-side grid class for EquipmentGrid defined in club_equipment.htm Jinja2 template, it uses built-in Grid instance from grid.js which is retrieved with .component() call on $('#equipment_grid') jQuery selector.
  • When grid class instance is available in local equipmentGrid variable, it’s rows are updated by calling equipmentGrid instance .updatePage(equipmentGridView) method.
  • Full code of client-side part is available in club-grid.js script.
  • See also dom_attrs argument of ko_grid() macro for explanation how grid component DOM id is set.

Custom action types

It is possible to define new grid action types. However to display these at client-side one has to use custom templates, which is explained in Modifying visual layout of grid section.

Let’s define new action type 'button_bottom', which will be displayed as grid action buttons below the grid rows, not above as standard 'button' action type actions.

First step is to override KoGridView class get_actions() method to return new grid action type with action definition(s):

class Model1Grid(KoGridView):

    model = Model1

    # ... skipped ...

    def get_actions(self):
        actions = super().get_actions()
        # Custom type UI actions.
        actions['button_bottom'] = OrderedDict([
            ('approve_user', {
                'localName': _('Approve user'),
                'css': {
                    'button': 'btn-warning',
                    'iconui': 'iconui-user'
                },
            })
        ])
        return actions

    def get_custom_meta(self):
        return {
            'user_name': str(self.user),
        }

    def get_ko_meta(self):
        meta = super().get_ko_meta()
        meta.update(self.get_custom_meta())
        return meta

    def action_approve_user(self):
        role = self.request.POST.get('role_str')
        self.user = self.request.POST.get('user_id')
        self.user.set_role(role)
        # Implement custom logic in user model:
        user.approve()
        return vm_list({
            'view': self.__class__.viewmodel_name,
            'title': format_html('User was approved {0}', self.user.username),
            'message': 'Congratulations, you were approved!',
            'meta': self.get_custom_meta(),
            'update_rows': [self.user]
        })

Note that we override get_ko_meta() method to automatically set the value of meta.user_name observable in Model1Grid Knockout.js bindings via Grid class built-in .loadMetaCallback() method.

Second step is to override uiActionTypes property of client-side Grid class to add 'button_bottom' to the list of interactive action types.

One also has to implement client-side handling methods for newly defined approve_user action. The following example assumes that the action will be performed as AJAX query / response with Model1Grid class action_approve_user() method:

import { inherit } from '../../djk/js/dash.js';
import { Grid } from '../../djk/js/grid.js';

Model1Grid = function(options) {
    inherit(Grid.prototype, this);
    this.init(options);
};

(function(Model1Grid) {

    Model1Grid.init = function(options) {
        this._super._call('init', options);
        this.meta.user_name = ko.observable();
    };

    Model1Grid.uiActionTypes = ['button', 'button_footer', 'pagination', 'click', 'iconui', 'button_bottom'];

    Model1Grid.iocGridActions = function(options) {
        return new Model1GridActions(options);
    };

    Model1Grid.getRoleFilterChoice = function() {
        return this.getKoFilter('role').getActiveChoices()[0];
    };

})(Model1Grid.prototype);

Mandatory (for server-side AJAX actions only) callback_approve_user method and optional queryargs_approve_user method are implemented to perform custom action (see Action AJAX response handler, Action queryargs):

import { inherit } from '../../djk/js/dash.js';
import { Dialog } from '../../djk/js/dialog.js';
import { GridActions } from '../../djk/js/grid/actions.js';

Model1GridActions = function(options) {
    inherit(GridActions.prototype, this);
    this.init(options);
};

(function(Model1GridActions) {

    Model1GridActions.queryargs_approve_user = function(options) {
        var roleFilterChoice = this.grid.getRoleFilterChoice();
        options['role_str'] = roleFilterChoice.value;
        return options;
    };

    Model1GridActions.callback_approve_user = function(viewModel) {
        // Update grid meta (visual appearance).
        this.grid.updateMeta(viewModel.meta);
        // Update grid rows.
        this.grid.updatePage(viewModel);
        // Display dialog with server-side title / message generated in Model1Grid.action_approve_user.
        var dialog = new Dialog(viewModel);
        dialog.alert();
    };

})(Model1GridActions.prototype);

And the final step is to generate client-side component in Jinja2 template with overridden ko_grid_body template

{% extends 'base_min.htm' %}
{% from 'bs_navs.htm' import bs_navs with context %}
{% from 'ko_grid.htm' import ko_grid with context %}
{% from 'ko_grid_body.htm' import ko_grid_body with context %}


{% block main %}

{{ bs_navs(main_navs) }}

{{ ko_grid(
    grid_options={
        'pageRoute': 'model1_grid',
    },
    dom_attrs={
        'id': 'model1_grid',
        'data-template-options': {
            'templates': {
                'ko_grid_body': 'model1_ko_grid_body',
            }
        },
    }
) }}

{% do page_context.set_custom_scripts(
    'sample/js/model1-grid.js',
) -%}

{% endblock main %}

{% block bottom_scripts %}
    {{ ko_grid_body() }}

    <script type="text/template" id="model1_ko_grid_body">
        <card-primary data-bind="using: $root, as: 'grid'">
            <card-header data-bind="text: meta.verboseNamePlural"></card-header>
            <card-body>
                <!-- ko if: meta.hasSearch() || gridFilters().length > 0 -->
                <div data-template-id="model1_ko_grid_nav"></div>
                <!-- /ko -->
                <div data-template-id="model1_ko_grid_table"></div>
                <!-- ko foreach: {data: actionTypes['button_bottom'], as: 'koAction'} -->
                    <button class="btn" data-bind="css: getKoCss('button'), click: function() { doAction({}); }">
                        <span class="iconui" data-bind="css: getKoCss('iconui')"></span>
                        <span data-bind="text: koAction.localName"></span>
                    </button>
                <!-- /ko -->
            </card-body>
        </card-primary>
    </script>

{% endblock bottom_scripts %}

Knockout.js foreach: {data: actionTypes['button_bottom'], as: 'koAction'} binding is very similar to standard 'button' type actions binding, defined in ko_grid_body.htm, with the exception that the buttons are placed below the grid table, not the above.

There is built-in Action type ‘button_footer’ available, which displays grid action buttons below the grid rows, so this code is not requited in recent versions of the framework, but still it provides an useful example to someone who wants to implement custom action types and their templates.

There is built-in Action type ‘pagination’ which allows to add iconui buttons with grid actions attached directly to datatable pagination list.