feat: add backend pagination support for grids
This commit is contained in:
parent
dd3d640b1c
commit
d151758c48
7 changed files with 501 additions and 29 deletions
|
@ -30,9 +30,11 @@ import logging
|
|||
|
||||
import sqlalchemy as sa
|
||||
|
||||
import paginate
|
||||
from pyramid.renderers import render
|
||||
from webhelpers2.html import HTML
|
||||
|
||||
from wuttaweb.db import Session
|
||||
from wuttaweb.util import FieldList, get_model_fields, make_json_safe
|
||||
|
||||
|
||||
|
@ -87,6 +89,9 @@ class Grid:
|
|||
model records) or else an object capable of producing such a
|
||||
list, e.g. SQLAlchemy query.
|
||||
|
||||
This is the "full" data set; see also
|
||||
:meth:`get_visible_data()`.
|
||||
|
||||
.. attribute:: labels
|
||||
|
||||
Dict of column label overrides.
|
||||
|
@ -114,9 +119,23 @@ class Grid:
|
|||
.. attribute:: paginated
|
||||
|
||||
Boolean indicating whether the grid data should be paginated
|
||||
vs. all data is shown at once. Default is ``False``.
|
||||
vs. all data shown at once. Default is ``False`` which means
|
||||
the full set of grid data is sent for each request.
|
||||
|
||||
See also :attr:`pagesize` and :attr:`page`.
|
||||
See also :attr:`pagesize` and :attr:`page`, and
|
||||
:attr:`paginate_on_backend`.
|
||||
|
||||
.. attribute:: paginate_on_backend
|
||||
|
||||
Boolean indicating whether the grid data should be paginated on
|
||||
the backend. Default is ``True`` which means only one "page"
|
||||
of data is sent to the client-side component.
|
||||
|
||||
If this is ``False``, the full set of grid data is sent for
|
||||
each request, and the client-side Vue component will handle the
|
||||
pagination.
|
||||
|
||||
Only relevant if :attr:`paginated` is also true.
|
||||
|
||||
.. attribute:: pagesize_options
|
||||
|
||||
|
@ -157,6 +176,7 @@ class Grid:
|
|||
actions=[],
|
||||
linked_columns=[],
|
||||
paginated=False,
|
||||
paginate_on_backend=True,
|
||||
pagesize_options=None,
|
||||
pagesize=None,
|
||||
page=1,
|
||||
|
@ -177,6 +197,7 @@ class Grid:
|
|||
self.set_columns(columns or self.get_columns())
|
||||
|
||||
self.paginated = paginated
|
||||
self.paginate_on_backend = paginate_on_backend
|
||||
self.pagesize_options = pagesize_options or self.get_pagesize_options()
|
||||
self.pagesize = pagesize or self.get_pagesize()
|
||||
self.page = page
|
||||
|
@ -435,6 +456,149 @@ class Grid:
|
|||
|
||||
return self.pagesize_options[0]
|
||||
|
||||
##############################
|
||||
# configuration methods
|
||||
##############################
|
||||
|
||||
def load_settings(self, store=True):
|
||||
"""
|
||||
Load all effective settings for the grid, from the following
|
||||
places:
|
||||
|
||||
* request params
|
||||
* user session
|
||||
|
||||
The first value found for a given setting will be applied to
|
||||
the grid.
|
||||
|
||||
.. note::
|
||||
|
||||
As of now, "pagination" settings are the only type
|
||||
supported by this logic. Filter/sort coming soon...
|
||||
|
||||
The overall logic for this method is as follows:
|
||||
|
||||
* collect settings
|
||||
* apply settings to current grid
|
||||
* optionally save settings to user session
|
||||
|
||||
Saving the settings to user session will allow the grid to
|
||||
"remember" its current settings when user refreshes the page.
|
||||
|
||||
:param store: Flag indicating whether the collected settings
|
||||
should then be saved to the user session.
|
||||
"""
|
||||
|
||||
# initial default settings
|
||||
settings = {}
|
||||
if self.paginated and self.paginate_on_backend:
|
||||
settings['pagesize'] = self.pagesize
|
||||
settings['page'] = self.page
|
||||
|
||||
# grab settings from request and/or user session
|
||||
if self.paginated and self.paginate_on_backend:
|
||||
self.update_page_settings(settings)
|
||||
|
||||
else:
|
||||
# no settings were found in request or user session, so
|
||||
# nothing needs to be saved
|
||||
store = False
|
||||
|
||||
# maybe store settings in user session, for next time
|
||||
if store:
|
||||
self.persist_settings(settings)
|
||||
|
||||
# update ourself to reflect settings
|
||||
if self.paginated and self.paginate_on_backend:
|
||||
self.pagesize = settings['pagesize']
|
||||
self.page = settings['page']
|
||||
|
||||
def request_has_settings(self):
|
||||
""" """
|
||||
for key in ['pagesize', 'page']:
|
||||
if key in self.request.GET:
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_page_settings(self, settings):
|
||||
""" """
|
||||
# update the settings dict from request and/or user session
|
||||
|
||||
# pagesize
|
||||
pagesize = self.request.GET.get('pagesize')
|
||||
if pagesize is not None:
|
||||
if pagesize.isdigit():
|
||||
settings['pagesize'] = int(pagesize)
|
||||
else:
|
||||
pagesize = self.request.session.get(f'grid.{self.key}.pagesize')
|
||||
if pagesize is not None:
|
||||
settings['pagesize'] = pagesize
|
||||
|
||||
# page
|
||||
page = self.request.GET.get('page')
|
||||
if page is not None:
|
||||
if page.isdigit():
|
||||
settings['page'] = int(page)
|
||||
else:
|
||||
page = self.request.session.get(f'grid.{self.key}.page')
|
||||
if page is not None:
|
||||
settings['page'] = int(page)
|
||||
|
||||
def persist_settings(self, settings):
|
||||
""" """
|
||||
model = self.app.model
|
||||
session = Session()
|
||||
|
||||
# func to save a setting value to user session
|
||||
def persist(key, value=lambda k: settings.get(k)):
|
||||
skey = f'grid.{self.key}.{key}'
|
||||
self.request.session[skey] = value(key)
|
||||
|
||||
if self.paginated and self.paginate_on_backend:
|
||||
persist('pagesize')
|
||||
persist('page')
|
||||
|
||||
##############################
|
||||
# data methods
|
||||
##############################
|
||||
|
||||
def get_visible_data(self):
|
||||
"""
|
||||
Returns the "effective" visible data for the grid.
|
||||
|
||||
This uses :attr:`data` as the starting point but may morph it
|
||||
for pagination etc. per the grid settings.
|
||||
|
||||
Code can either access :attr:`data` directly, or call this
|
||||
method to get only the data for current view (e.g. assuming
|
||||
pagination is used), depending on the need.
|
||||
|
||||
See also these methods which may be called by this one:
|
||||
|
||||
* :meth:`paginate_data()`
|
||||
"""
|
||||
data = self.data or []
|
||||
|
||||
if self.paginated and self.paginate_on_backend:
|
||||
self.pager = self.paginate_data(data)
|
||||
data = self.pager
|
||||
|
||||
return data
|
||||
|
||||
def paginate_data(self, data):
|
||||
"""
|
||||
Apply pagination to the given data set, based on grid settings.
|
||||
|
||||
This returns a "pager" object which can then be used as a
|
||||
"data replacement" in subsequent logic.
|
||||
|
||||
This method is called by :meth:`get_visible_data()`.
|
||||
"""
|
||||
pager = paginate.Page(data,
|
||||
items_per_page=self.pagesize,
|
||||
page=self.page)
|
||||
return pager
|
||||
|
||||
##############################
|
||||
# rendering methods
|
||||
##############################
|
||||
|
@ -517,17 +681,18 @@ class Grid:
|
|||
"""
|
||||
Returns a list of Vue-compatible data records.
|
||||
|
||||
This uses :attr:`data` as the basis, but may add some extra
|
||||
values to each record, e.g. URLs for :attr:`actions` etc.
|
||||
This calls :meth:`get_visible_data()` but then may modify the
|
||||
result, e.g. to add URLs for :attr:`actions` etc.
|
||||
|
||||
Importantly, this also ensures each value in the dict is
|
||||
JSON-serializable, using
|
||||
:func:`~wuttaweb.util.make_json_safe()`.
|
||||
|
||||
:returns: List of data record dicts for use with Vue table
|
||||
component.
|
||||
component. May be the full set of data, or just the
|
||||
current page, per :attr:`paginate_on_backend`.
|
||||
"""
|
||||
original_data = self.data or []
|
||||
original_data = self.get_visible_data()
|
||||
|
||||
# TODO: at some point i thought it was useful to wrangle the
|
||||
# columns here, but now i can't seem to figure out why..?
|
||||
|
@ -578,6 +743,22 @@ class Grid:
|
|||
|
||||
return data
|
||||
|
||||
def get_vue_pager_stats(self):
|
||||
"""
|
||||
Returns a simple dict with current grid pager stats.
|
||||
|
||||
This is used when :attr:`paginate_on_backend` is in effect.
|
||||
"""
|
||||
pager = self.pager
|
||||
return {
|
||||
'item_count': pager.item_count,
|
||||
'items_per_page': pager.items_per_page,
|
||||
'page': pager.page,
|
||||
'page_count': pager.page_count,
|
||||
'first_item': pager.first_item,
|
||||
'last_item': pager.last_item,
|
||||
}
|
||||
|
||||
|
||||
class GridAction:
|
||||
"""
|
||||
|
|
|
@ -14,6 +14,11 @@
|
|||
pagination-size="is-small"
|
||||
:per-page="perPage"
|
||||
:current-page="currentPage"
|
||||
@page-change="onPageChange"
|
||||
% if grid.paginate_on_backend:
|
||||
backend-pagination
|
||||
:total="pagerStats.item_count"
|
||||
% endif
|
||||
% endif
|
||||
>
|
||||
|
||||
|
@ -53,8 +58,14 @@
|
|||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<span>
|
||||
showing
|
||||
{{ renderNumber(pagerStats.first_item) }}
|
||||
- {{ renderNumber(pagerStats.last_item) }}
|
||||
of {{ renderNumber(pagerStats.item_count) }} results;
|
||||
</span>
|
||||
<b-select v-model="perPage"
|
||||
% if grid.paginate_on_backend:
|
||||
@input="onPageSizeChange"
|
||||
% endif
|
||||
size="is-small">
|
||||
<option v-for="size in pageSizeOptions"
|
||||
:value="size">
|
||||
|
@ -74,7 +85,7 @@
|
|||
|
||||
<script>
|
||||
|
||||
const ${grid.vue_component}CurrentData = ${json.dumps(grid.get_vue_data())|n}
|
||||
let ${grid.vue_component}CurrentData = ${json.dumps(grid.get_vue_data())|n}
|
||||
|
||||
const ${grid.vue_component}Data = {
|
||||
data: ${grid.vue_component}CurrentData,
|
||||
|
@ -85,12 +96,120 @@
|
|||
pageSizeOptions: ${json.dumps(grid.pagesize_options)|n},
|
||||
perPage: ${json.dumps(grid.pagesize)|n},
|
||||
currentPage: ${json.dumps(grid.page)|n},
|
||||
% if grid.paginate_on_backend:
|
||||
pagerStats: ${json.dumps(grid.get_vue_pager_stats())|n},
|
||||
% endif
|
||||
% endif
|
||||
}
|
||||
|
||||
const ${grid.vue_component} = {
|
||||
template: '#${grid.vue_tagname}-template',
|
||||
methods: {},
|
||||
computed: {
|
||||
|
||||
% if not grid.paginate_on_backend:
|
||||
|
||||
pagerStats() {
|
||||
let last = this.currentPage * this.perPage
|
||||
let first = last - this.perPage + 1
|
||||
if (last > this.data.length) {
|
||||
last = this.data.length
|
||||
}
|
||||
return {
|
||||
'item_count': this.data.length,
|
||||
'items_per_page': this.perPage,
|
||||
'page': this.currentPage,
|
||||
'first_item': first,
|
||||
'last_item': last,
|
||||
}
|
||||
},
|
||||
|
||||
% endif
|
||||
},
|
||||
methods: {
|
||||
|
||||
renderNumber(value) {
|
||||
if (value != undefined) {
|
||||
return value.toLocaleString('en')
|
||||
}
|
||||
},
|
||||
|
||||
getBasicParams() {
|
||||
return {
|
||||
% if grid.paginated and grid.paginate_on_backend:
|
||||
pagesize: this.perPage,
|
||||
page: this.currentPage,
|
||||
% endif
|
||||
}
|
||||
},
|
||||
|
||||
async fetchData(params, success, failure) {
|
||||
|
||||
if (params === undefined || params === null) {
|
||||
params = new URLSearchParams(this.getBasicParams())
|
||||
} else {
|
||||
params = new URLSearchParams(params)
|
||||
}
|
||||
if (!params.has('partial')) {
|
||||
params.append('partial', true)
|
||||
}
|
||||
params = params.toString()
|
||||
|
||||
this.loading = true
|
||||
this.$http.get(`${request.path_url}?${'$'}{params}`).then(response => {
|
||||
console.log(response)
|
||||
console.log(response.data)
|
||||
if (!response.data.error) {
|
||||
${grid.vue_component}CurrentData = response.data.data
|
||||
this.data = ${grid.vue_component}CurrentData
|
||||
% if grid.paginated and grid.paginate_on_backend:
|
||||
this.pagerStats = response.data.pager_stats
|
||||
% endif
|
||||
this.loading = false
|
||||
if (success) {
|
||||
success()
|
||||
}
|
||||
} else {
|
||||
this.$buefy.toast.open({
|
||||
message: data.error,
|
||||
type: 'is-danger',
|
||||
duration: 2000, // 4 seconds
|
||||
})
|
||||
this.loading = false
|
||||
if (failure) {
|
||||
failure()
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.data = []
|
||||
% if grid.paginated and grid.paginate_on_backend:
|
||||
this.pagerStats = {}
|
||||
% endif
|
||||
this.loading = false
|
||||
if (failure) {
|
||||
failure()
|
||||
}
|
||||
throw error
|
||||
})
|
||||
},
|
||||
|
||||
% if grid.paginated:
|
||||
|
||||
% if grid.paginate_on_backend:
|
||||
onPageSizeChange(size) {
|
||||
this.fetchData()
|
||||
},
|
||||
% endif
|
||||
|
||||
onPageChange(page) {
|
||||
this.currentPage = page
|
||||
% if grid.paginate_on_backend:
|
||||
this.fetchData()
|
||||
% endif
|
||||
},
|
||||
|
||||
% endif
|
||||
},
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
|
@ -25,6 +25,7 @@ Base Logic for Views
|
|||
"""
|
||||
|
||||
from pyramid import httpexceptions
|
||||
from pyramid.renderers import render_to_response
|
||||
|
||||
from wuttaweb import forms, grids
|
||||
|
||||
|
@ -117,3 +118,13 @@ class View:
|
|||
correctly no matter what.
|
||||
"""
|
||||
return httpexceptions.HTTPFound(location=url, **kwargs)
|
||||
|
||||
def json_response(self, context):
|
||||
"""
|
||||
Convenience method to return a JSON response.
|
||||
|
||||
:param context: Context data to be rendered as JSON.
|
||||
|
||||
:returns: A :term:`response` with JSON content type.
|
||||
"""
|
||||
return render_to_response('json', context, request=self.request)
|
||||
|
|
|
@ -189,6 +189,15 @@ class MasterView(View):
|
|||
This is used by :meth:`make_model_grid()` to set the grid's
|
||||
:attr:`~wuttaweb.grids.base.Grid.paginated` flag.
|
||||
|
||||
.. attribute:: paginate_on_backend
|
||||
|
||||
Boolean indicating whether the grid data for the
|
||||
:meth:`index()` view should be paginated on the backend.
|
||||
Default is ``True``.
|
||||
|
||||
This is used by :meth:`make_model_grid()` to set the grid's
|
||||
:attr:`~wuttaweb.grids.base.Grid.paginate_on_backend` flag.
|
||||
|
||||
.. attribute:: creatable
|
||||
|
||||
Boolean indicating whether the view model supports "creating" -
|
||||
|
@ -238,6 +247,7 @@ class MasterView(View):
|
|||
listable = True
|
||||
has_grid = True
|
||||
paginated = True
|
||||
paginate_on_backend = True
|
||||
creatable = True
|
||||
viewable = True
|
||||
editable = True
|
||||
|
@ -284,7 +294,16 @@ class MasterView(View):
|
|||
}
|
||||
|
||||
if self.has_grid:
|
||||
context['grid'] = self.make_model_grid()
|
||||
grid = self.make_model_grid()
|
||||
|
||||
# so-called 'partial' requests get just data, no html
|
||||
if self.request.GET.get('partial'):
|
||||
context = {'data': grid.get_vue_data()}
|
||||
if grid.paginated and grid.paginate_on_backend:
|
||||
context['pager_stats'] = grid.get_vue_pager_stats()
|
||||
return self.json_response(context)
|
||||
|
||||
context['grid'] = grid
|
||||
|
||||
return self.render_to_response('index', context)
|
||||
|
||||
|
@ -1071,9 +1090,11 @@ class MasterView(View):
|
|||
kwargs['actions'] = actions
|
||||
|
||||
kwargs.setdefault('paginated', self.paginated)
|
||||
kwargs.setdefault('paginate_on_backend', self.paginate_on_backend)
|
||||
|
||||
grid = self.make_grid(**kwargs)
|
||||
self.configure_grid(grid)
|
||||
grid.load_settings()
|
||||
return grid
|
||||
|
||||
def get_grid_columns(self):
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue