Show full version history within the "view" page

avoid full page loads when navigating version history
This commit is contained in:
Lance Edgar 2023-10-10 10:54:16 -05:00
parent 44112a3a4b
commit 4328b9e385
9 changed files with 498 additions and 130 deletions

View file

@ -136,6 +136,9 @@ class VersionDiff(Diff):
"""
def __init__(self, version, *args, **kwargs):
self.version = version
self.mapper = sa.inspect(continuum.parent_class(type(self.version)))
self.version_mapper = sa.inspect(type(self.version))
self.title = kwargs.pop('title', None)
if 'nature' not in kwargs:
@ -146,10 +149,31 @@ class VersionDiff(Diff):
else:
kwargs['nature'] = 'new'
if 'fields' not in kwargs:
kwargs['fields'] = self.get_default_fields()
if not args:
old_data = {}
new_data = {}
for field in kwargs['fields']:
if version.previous:
old_data[field] = getattr(version.previous, field)
new_data[field] = getattr(version, field)
args = (old_data, new_data)
super().__init__(*args, **kwargs)
self.version = version
self.mapper = sa.inspect(continuum.parent_class(type(self.version)))
def get_default_fields(self):
fields = sorted(self.version_mapper.columns.keys())
unwanted = [
'transaction_id',
'end_transaction_id',
'operation_type',
]
return [field for field in fields
if field not in unwanted]
def render_version_value(self, field, value, version):
text = HTML.tag('span', c=[repr(value)],

View file

@ -1334,6 +1334,7 @@ class Grid(object):
context['grid'] = self
context['request'] = self.request
context.setdefault('allow_save_defaults', True)
context.setdefault('view_click_handler', self.get_view_click_handler())
return render(template, context)
def render_buefy(self, template='/grids/buefy.mako', **kwargs):
@ -1374,6 +1375,10 @@ class Grid(object):
context.setdefault('paginated', False)
if context['paginated']:
context.setdefault('per_page', 20)
context['view_click_handler'] = self.get_view_click_handler()
return render(template, context)
def get_view_click_handler(self):
# locate the 'view' action
# TODO: this should be easier, and/or moved elsewhere?
@ -1388,11 +1393,8 @@ class Grid(object):
view = action
break
context['view_click_handler'] = None
if view and view.click_handler:
context['view_click_handler'] = view.click_handler
return render(template, context)
if view:
return view.click_handler
def set_filters_sequence(self, filters, only=False):
"""

View file

@ -61,13 +61,14 @@ header .level .theme-picker {
display: inline-flex;
}
#content-title {
padding: 0.3rem;
}
#content-title h1 {
font-size: 2rem;
margin-left: 1rem;
margin-bottom: 0;
margin-right: 1rem;
max-width: 50%;
overflow: hidden;
padding: 0 0.3rem;
text-overflow: ellipsis;
white-space: nowrap;
}
/******************************

View file

@ -426,17 +426,22 @@
## Page Title
% if capture(self.content_title):
<section id="content-title" class="hero is-primary">
<div class="level">
<div class="level-left">
<div class="level-item">
<h1 class="title" v-html="contentTitleHTML"></h1>
</div>
<section id="content-title"
class="has-background-primary">
<div style="display: flex; align-items: center; padding: 0.5rem;">
<h1 class="title has-text-white"
v-html="contentTitleHTML">
</h1>
<div style="flex-grow: 1; display: flex; gap: 0.5rem;">
${self.render_instance_header_title_extras()}
</div>
<div class="level-right">
<div style="display: flex; gap: 0.5rem;">
${self.render_instance_header_buttons()}
</div>
</div>
</section>
% endif
@ -634,76 +639,60 @@
## TODO: is there a better way to check if viewing parent?
% if parent_instance is Undefined:
% if master.editable and instance_editable and master.has_perm('edit'):
<div class="level-item">
<once-button tag="a" href="${action_url('edit', instance)}"
icon-left="edit"
text="Edit This">
</once-button>
</div>
% endif
% if master.cloneable and master.has_perm('clone'):
<div class="level-item">
<once-button tag="a" href="${action_url('clone', instance)}"
icon-left="object-ungroup"
text="Clone This">
</once-button>
</div>
% endif
% if master.deletable and instance_deletable and master.has_perm('delete'):
<div class="level-item">
<once-button tag="a" href="${action_url('delete', instance)}"
type="is-danger"
icon-left="trash"
text="Delete This">
</once-button>
</div>
% endif
% else:
## viewing row
% if instance_deletable and master.has_perm('delete_row'):
<div class="level-item">
<once-button tag="a" href="${action_url('delete', instance)}"
type="is-danger"
icon-left="trash"
text="Delete This">
</once-button>
</div>
% endif
% endif
% elif master and master.editing:
% if master.viewable and master.has_perm('view'):
<div class="level-item">
<once-button tag="a" href="${action_url('view', instance)}"
icon-left="eye"
text="View This">
</once-button>
</div>
% endif
% if master.deletable and instance_deletable and master.has_perm('delete'):
<div class="level-item">
<once-button tag="a" href="${action_url('delete', instance)}"
type="is-danger"
icon-left="trash"
text="Delete This">
</once-button>
</div>
% endif
% elif master and master.deleting:
% if master.viewable and master.has_perm('view'):
<div class="level-item">
<once-button tag="a" href="${action_url('view', instance)}"
icon-left="eye"
text="View This">
</once-button>
</div>
% endif
% if master.editable and instance_editable and master.has_perm('edit'):
<div class="level-item">
<once-button tag="a" href="${action_url('edit', instance)}"
icon-left="edit"
text="Edit This">
</once-button>
</div>
% endif
% endif
</%def>
@ -711,40 +700,32 @@
<%def name="render_prevnext_header_buttons()">
% if show_prev_next is not Undefined and show_prev_next:
% if prev_url:
<div class="level-item">
<b-button tag="a" href="${prev_url}"
icon-pack="fas"
icon-left="arrow-left">
Older
</b-button>
</div>
% else:
<div class="level-item">
<b-button tag="a" href="#"
disabled
icon-pack="fas"
icon-left="arrow-left">
Older
</b-button>
</div>
% endif
% if next_url:
<div class="level-item">
<b-button tag="a" href="${next_url}"
icon-pack="fas"
icon-left="arrow-right">
Newer
</b-button>
</div>
% else:
<div class="level-item">
<b-button tag="a" href="#"
disabled
icon-pack="fas"
icon-left="arrow-right">
Newer
</b-button>
</div>
% endif
% endif
</%def>

View file

@ -254,7 +254,12 @@
% if column['field'] in grid.raw_renderers:
${grid.raw_renderers[column['field']]()}
% elif grid.is_linked(column['field']):
<a :href="props.row._action_url_view" v-html="props.row.${column['field']}"></a>
<a :href="props.row._action_url_view"
% if view_click_handler:
@click.prevent="${view_click_handler}"
% endif
v-html="props.row.${column['field']}">
</a>
% else:
<span v-html="props.row.${column['field']}"></span>
% endif
@ -274,6 +279,9 @@
% if action.click_handler:
@click.prevent="${action.click_handler}"
% endif
% if action.target:
target="${action.target}"
% endif
>
${action.render_icon()|n}
${action.render_label()|n}
@ -533,7 +541,7 @@
## TODO: i noticed buefy docs show using `async` keyword here,
## so now i am too. knowing nothing at all of if/how this is
## supposed to improve anything. we shall see i guess
async loadAsyncData(params, callback) {
async loadAsyncData(params, success, failure) {
if (params === undefined || params === null) {
params = new URLSearchParams(this.getBasicParams())
@ -551,14 +559,17 @@
this.lastItem = data.last_item
this.loading = false
this.checkedRows = this.locateCheckedRows(data.checked_rows)
if (callback) {
callback()
if (success) {
success()
}
})
.catch((error) => {
this.data = []
this.total = 0
this.loading = false
if (failure) {
failure()
}
throw error
})
},

View file

@ -1,7 +1,8 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/form.mako" />
<%def name="title()">Edit: ${instance_title}</%def>
<%def name="title()">${index_title} &raquo; ${instance_title} &raquo; Edit</%def>
<%def name="content_title()">Edit: ${instance_title}</%def>
${parent.body()}

View file

@ -8,7 +8,6 @@
</%def>
<%def name="render_instance_header_title_extras()">
<span style="width: 2rem;"></span>
% if master.touchable and master.has_perm('touch'):
<b-button title="&quot;Touch&quot; this record to trigger sync"
icon-pack="fas"
@ -17,6 +16,13 @@
:disabled="touchSubmitting">
</b-button>
% endif
% if expose_versions:
<b-button icon-pack="fas"
icon-left="history"
@click="viewingHistory = !viewingHistory">
{{ viewingHistory ? "View Current" : "View History" }}
</b-button>
% endif
</%def>
<%def name="object_helpers()">
@ -46,9 +52,6 @@
## TODO: either make this configurable, or just lose it.
## nobody seems to ever find it useful in practice.
## <li>${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}</li>
% if master.has_versions and request.rattail_config.versioning_enabled() and request.has_perm('{}.versions'.format(permission_prefix)):
<li>${h.link_to("Version History", action_url('versions', instance))}</li>
% endif
</%def>
<%def name="render_row_grid_tools()">
@ -69,8 +72,30 @@
% endif
</%def>
<%def name="render_this_page_component()">
## TODO: should override this in a cleaner way! too much duplicate code w/ parent template
<this-page @change-content-title="changeContentTitle"
% if can_edit_help:
:configure-fields-help="configureFieldsHelp"
% endif
% if expose_versions:
:viewing-history="viewingHistory"
% endif
>
</this-page>
</%def>
<%def name="render_this_page()">
<div
% if expose_versions:
v-show="!viewingHistory"
% endif
>
## render main form
${parent.render_this_page()}
## render row grid
% if master.has_rows:
<br />
% if rows_title:
@ -78,6 +103,122 @@
% endif
${self.render_row_grid_component()}
% endif
</div>
% if expose_versions:
<div v-show="viewingHistory">
<div style="display: flex; align-items: center; gap: 2rem;">
<h3 class="is-size-3">Version History</h3>
<p class="block">
<a href="${master.get_action_url('versions', instance)}"
target="_blank">
<i class="fas fa-external-link-alt"></i>
View as separate page
</a>
</p>
</div>
<versions-grid ref="versionsGrid"
@view-revision="viewRevision">
</versions-grid>
<b-modal :active.sync="viewVersionShowDialog" :width="1200">
<div class="card">
<div class="card-content">
<div style="display: flex; flex-direction: column; gap: 1.5rem;">
<div style="display: flex; gap: 1rem;">
<div style="flex-grow: 1;">
<b-field horizontal label="Changed">
<div v-html="viewVersionData.changed"></div>
</b-field>
<b-field horizontal label="Changed by">
<div v-html="viewVersionData.changed_by"></div>
</b-field>
<b-field horizontal label="IP Address">
<div v-html="viewVersionData.remote_addr"></div>
</b-field>
<b-field horizontal label="Comment">
<div v-html="viewVersionData.comment"></div>
</b-field>
<b-field horizontal label="TXN ID">
<div v-html="viewVersionData.txnid"></div>
</b-field>
</div>
<div style="display: flex; flex-direction: column; justify-content: space-between;">
<div class="buttons">
<b-button @click="viewPrevRevision()"
type="is-primary"
icon-pack="fas"
icon-left="arrow-left"
:disabled="!viewVersionData.prev_txnid">
Older
</b-button>
<b-button @click="viewNextRevision()"
type="is-primary"
icon-pack="fas"
icon-right="arrow-right"
:disabled="!viewVersionData.next_txnid">
Newer
</b-button>
</div>
<div>
<a :href="viewVersionData.url"
target="_blank">
<i class="fas fa-external-link-alt"></i>
View as separate page
</a>
</div>
<b-button @click="toggleVersionFields()">
{{ viewVersionShowAllFields ? "Show Diffs Only" : "Show All Fields" }}
</b-button>
</div>
</div>
<div v-for="version in viewVersionData.versions"
:key="version.key">
<p class="block has-text-weight-bold">
{{ version.model_title }}
</p>
<table class="diff monospace is-size-7"
:class="version.diff_class">
<thead>
<tr>
<th>field name</th>
<th>old value</th>
<th>new value</th>
</tr>
</thead>
<tbody>
<tr v-for="field in version.fields"
:key="field"
:class="{diff: version.values[field].after != version.values[field].before}"
v-show="viewVersionShowAllFields || version.values[field].after != version.values[field].before">
<td class="field has-text-weight-bold">{{ field }}</td>
<td class="old-value" v-html="version.values[field].before"></td>
<td class="new-value" v-html="version.values[field].after"></td>
</tr>
</tbody>
</table>
</div>
</div>
<b-loading :active.sync="viewVersionLoading" :is-full-page="false"></b-loading>
</div>
</div>
</b-modal>
</div>
% endif
</%def>
<%def name="render_row_grid_component()">
@ -90,13 +231,80 @@
${rows_grid.render_buefy(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))|n}
% endif
${parent.render_this_page_template()}
% if expose_versions:
${versions_grid.render_buefy()|n}
% endif
</%def>
<%def name="modify_this_page_vars()">
${parent.modify_this_page_vars()}
% if expose_versions:
<script type="text/javascript">
ThisPage.props.viewingHistory = Boolean
ThisPageData.gettingRevisions = false
ThisPageData.gotRevisions = false
ThisPageData.viewVersionShowDialog = false
ThisPageData.viewVersionData = {}
ThisPageData.viewVersionShowAllFields = false
ThisPageData.viewVersionLoading = false
// auto-fetch grid results when first viewing history
ThisPage.watch.viewingHistory = function(newval, oldval) {
if (!this.gotRevisions && !this.gettingRevisions) {
this.gettingRevisions = true
this.$refs.versionsGrid.loadAsyncData(null, () => {
this.gettingRevisions = false
this.gotRevisions = true
}, () => {
this.gettingRevisions = false
})
}
}
VersionsGrid.methods.viewRevision = function(row) {
this.$emit('view-revision', row)
}
ThisPage.methods.viewRevision = function(row) {
this.viewVersionLoading = true
let url = '${master.get_action_url('revisions_data', instance)}'
let params = {txnid: row.id}
this.simpleGET(url, params, response => {
this.viewVersionData = response.data
this.viewVersionLoading = false
}, response => {
this.viewVersionLoading = false
})
this.viewVersionShowDialog = true
}
ThisPage.methods.viewPrevRevision = function() {
this.viewRevision({id: this.viewVersionData.prev_txnid})
}
ThisPage.methods.viewNextRevision = function() {
this.viewRevision({id: this.viewVersionData.next_txnid})
}
ThisPage.methods.toggleVersionFields = function() {
this.viewVersionShowAllFields = !this.viewVersionShowAllFields
}
</script>
% endif
</%def>
<%def name="modify_whole_page_vars()">
${parent.modify_whole_page_vars()}
% if master.touchable and master.has_perm('touch'):
<script type="text/javascript">
% if master.touchable and master.has_perm('touch'):
WholePageData.touchSubmitting = false
WholePage.methods.touchRecord = function() {
@ -104,21 +312,30 @@
location.href = '${master.get_action_url('touch', instance)}'
}
</script>
% endif
% if expose_versions:
WholePageData.viewingHistory = false
% endif
</script>
</%def>
<%def name="finalize_this_page_vars()">
${parent.finalize_this_page_vars()}
% if master.has_rows:
<script type="text/javascript">
% if master.has_rows:
TailboneGrid.data = function() { return TailboneGridData }
Vue.component('tailbone-grid', TailboneGrid)
% endif
% if expose_versions:
VersionsGrid.data = function() { return VersionsGridData }
Vue.component('versions-grid', VersionsGrid)
% endif
</script>
% endif
</%def>

View file

@ -45,13 +45,18 @@
<div class="field">${transaction.meta.get('comment') or ''}</div>
</div>
<div class="field-wrapper">
<label>TXN ID</label>
<div class="field">${transaction.id}</div>
</div>
</div>
</div><!-- form-wrapper -->
<div class="versions-wrapper">
% for diff in version_diffs:
<h2>${diff.title}</h2>
<h4 class="is-size-4 block">${diff.title}</h4>
${diff.render_html()}
% endfor
</div>

View file

@ -1172,6 +1172,12 @@ class MasterView(View):
context['rows_grid'] = grid
context['rows_grid_tools'] = HTML(self.make_row_grid_tools(instance) or '').strip()
context['expose_versions'] = (self.has_versions
and self.request.rattail_config.versioning_enabled()
and self.has_perm('versions'))
if context['expose_versions']:
context['versions_grid'] = self.make_revisions_grid(instance, empty_data=True)
return self.render_to_response('view', context)
def image(self):
@ -1300,7 +1306,7 @@ class MasterView(View):
return cls.version_grid_key
return '{}.history'.format(cls.get_route_prefix())
def get_version_data(self, instance):
def get_version_data(self, instance, order_by=True):
"""
Generate the base data set for the version grid.
"""
@ -1308,7 +1314,9 @@ class MasterView(View):
transaction_class = continuum.transaction_class(model_class)
query = model_transaction_query(self.Session(), instance, model_class,
child_classes=self.normalize_version_child_classes())
return query.order_by(transaction_class.issued_at.desc())
if order_by:
query = query.order_by(transaction_class.issued_at.desc())
return query
def get_version_child_classes(self):
"""
@ -1330,6 +1338,114 @@ class MasterView(View):
classes.append(cls)
return classes
def make_revisions_grid(self, obj, empty_data=False):
route_prefix = self.get_route_prefix()
row_url = lambda txn, i: self.request.route_url(f'{route_prefix}.version',
uuid=obj.uuid,
txnid=txn.id)
kwargs = {
'component': 'versions-grid',
'ajax_data_url': self.get_action_url('revisions_data', obj),
'sortable': True,
'default_sortkey': 'changed',
'default_sortdir': 'desc',
'main_actions': [
self.make_action('view', icon='eye', url='#',
click_handler='viewRevision(props.row)'),
self.make_action('view_separate', url=row_url, target='_blank',
icon='external-link-alt', ),
],
}
if empty_data:
# TODO: surely there is a better way to have empty initial
# data..? but so much logic depends on a query, can't
# just pass empty list here
txn_class = continuum.transaction_class(self.get_model_class())
meta_class = continuum.versioning_manager.transaction_meta_cls
kwargs['data'] = self.Session.query(txn_class)\
.outerjoin(meta_class,
meta_class.transaction_id == txn_class.id)\
.filter(txn_class.id == -1)
else:
kwargs['data'] = self.get_version_data(obj, order_by=False)
grid = self.make_version_grid(**kwargs)
grid.set_joiner('user', lambda q: q.outerjoin(self.model.User))
grid.set_sorter('user', self.model.User.username)
grid.set_link('remote_addr')
grid.append('id')
grid.set_label('id', "TXN ID")
grid.set_link('id')
return grid
def revisions_data(self):
"""
AJAX view to fetch revision data for current instance.
"""
txnid = self.request.GET.get('txnid')
if txnid:
# return single txn data
app = self.get_rattail_app()
obj = self.get_instance()
cls = self.get_model_class()
txn_cls = continuum.transaction_class(cls)
route_prefix = self.get_route_prefix()
transactions = model_transaction_query(
self.Session(), obj, cls,
child_classes=self.normalize_version_child_classes())
txn = transactions.filter(txn_cls.id == txnid).first()
if not txn:
return self.notfound()
older = transactions.filter(txn_cls.issued_at <= txn.issued_at)\
.filter(txn_cls.id != txnid)\
.order_by(txn_cls.issued_at.desc())\
.first()
newer = transactions.filter(txn_cls.issued_at >= txn.issued_at)\
.filter(txn_cls.id != txnid)\
.order_by(txn_cls.issued_at)\
.first()
version_diffs = []
for version in self.get_relevant_versions(txn, obj):
diff = self.make_version_diff(version)
version_diffs.append(diff.as_struct())
changed_raw = app.render_datetime(app.localtime(txn.issued_at, from_utc=True))
changed_ago = app.render_time_ago(app.make_utc() - txn.issued_at)
changed_by = str(txn.user)
if self.request.has_perm('users.view'):
changed_by = tags.link_to(changed_by, self.request.route_url('users.view', uuid=txn.user.uuid))
return {
'txnid': txn.id,
'changed': f"{changed_raw} ({changed_ago})",
'changed_by': changed_by,
'remote_addr': txn.remote_addr,
'comment': txn.meta.get('comment'),
'versions': version_diffs,
'url': self.request.route_url(f'{route_prefix}.version', uuid=obj.uuid, txnid=txnid),
'prev_txnid': older.id if older else None,
'next_txnid': newer.id if newer else None,
}
else: # no txnid, return grid data
obj = self.get_instance()
grid = self.make_revisions_grid(obj)
return grid.get_buefy_data()
def view_version(self):
"""
View showing diff details of a particular object version.
@ -4829,10 +4945,10 @@ class MasterView(View):
def make_diff(self, old_data, new_data, **kwargs):
return diffs.Diff(old_data, new_data, **kwargs)
def make_version_diff(self, version, old_data, new_data, **kwargs):
def make_version_diff(self, version, *args, **kwargs):
if 'title' not in kwargs:
kwargs['title'] = self.title_for_version(version)
return diffs.VersionDiff(version, old_data, new_data, **kwargs)
return diffs.VersionDiff(version, *args, **kwargs)
##############################
# Configuration Views
@ -5576,6 +5692,16 @@ class MasterView(View):
route_name='{}.version'.format(route_prefix),
permission='{}.versions'.format(permission_prefix))
# revisions data (AJAX)
config.add_route(f'{route_prefix}.revisions_data',
f'{instance_url_prefix}/revisions-data',
request_method='GET')
config.add_view(cls, attr='revisions_data',
route_name=f'{route_prefix}.revisions_data',
permission=f'{permission_prefix}.versions',
renderer='json')
@classmethod
def _defaults_edit_help(cls, config, **kwargs):
route_prefix = cls.get_route_prefix()