feat: add new 'waterpark' theme, based on wuttaweb w/ vue2 + buefy

hoping to eventually replace the 'default' view with this one, if all
goes well.  definitely needs more testing and is not exposed as an
option yet, unless configured
This commit is contained in:
Lance Edgar 2024-08-20 13:46:40 -05:00
parent 1ec1eba496
commit 59bd58aca7
23 changed files with 937 additions and 44 deletions

View file

@ -321,7 +321,8 @@ def main(global_config, **settings):
""" """
This function returns a Pyramid WSGI application. This function returns a Pyramid WSGI application.
""" """
settings.setdefault('mako.directories', ['tailbone:templates']) settings.setdefault('mako.directories', ['tailbone:templates',
'wuttaweb:templates'])
rattail_config = make_rattail_config(settings) rattail_config = make_rattail_config(settings)
pyramid_config = make_pyramid_config(settings) pyramid_config = make_pyramid_config(settings)
pyramid_config.include('tailbone') pyramid_config.include('tailbone')

View file

@ -905,7 +905,8 @@ class Form(object):
def render_vue_template(self, template='/forms/deform.mako', **context): def render_vue_template(self, template='/forms/deform.mako', **context):
""" """ """ """
return self.render_deform(template=template, **context) output = self.render_deform(template=template, **context)
return HTML.literal(output)
def render_deform(self, dform=None, template=None, **kwargs): def render_deform(self, dform=None, template=None, **kwargs):
if not template: if not template:
@ -1220,6 +1221,18 @@ class Form(object):
# TODO: again, why does serialize() not return literal? # TODO: again, why does serialize() not return literal?
return HTML.literal(field.serialize()) return HTML.literal(field.serialize())
# TODO: this was copied from wuttaweb; can remove when we align
# Form class structure
def render_vue_finalize(self):
""" """
set_data = f"{self.vue_component}.data = function() {{ return {self.vue_component}Data }}"
make_component = f"Vue.component('{self.vue_tagname}', {self.vue_component})"
return HTML.tag('script', c=['\n',
HTML.literal(set_data),
'\n',
HTML.literal(make_component),
'\n'])
def render_field_readonly(self, field_name, **kwargs): def render_field_readonly(self, field_name, **kwargs):
""" """
Render the given field completely, but in read-only fashion. Render the given field completely, but in read-only fashion.

View file

@ -216,39 +216,39 @@ class Grid(WuttaGrid):
expose_direct_link=False, expose_direct_link=False,
**kwargs, **kwargs,
): ):
if kwargs.get('component'): if 'component' in kwargs:
warnings.warn("component param is deprecated for Grid(); " warnings.warn("component param is deprecated for Grid(); "
"please use vue_tagname param instead", "please use vue_tagname param instead",
DeprecationWarning, stacklevel=2) DeprecationWarning, stacklevel=2)
kwargs.setdefault('vue_tagname', kwargs.pop('component')) kwargs.setdefault('vue_tagname', kwargs.pop('component'))
if kwargs.get('default_sortkey'): if 'default_sortkey' in kwargs:
warnings.warn("default_sortkey param is deprecated for Grid(); " warnings.warn("default_sortkey param is deprecated for Grid(); "
"please use sort_defaults param instead", "please use sort_defaults param instead",
DeprecationWarning, stacklevel=2) DeprecationWarning, stacklevel=2)
if kwargs.get('default_sortdir'): if 'default_sortdir' in kwargs:
warnings.warn("default_sortdir param is deprecated for Grid(); " warnings.warn("default_sortdir param is deprecated for Grid(); "
"please use sort_defaults param instead", "please use sort_defaults param instead",
DeprecationWarning, stacklevel=2) DeprecationWarning, stacklevel=2)
if kwargs.get('default_sortkey') or kwargs.get('default_sortdir'): if 'default_sortkey' in kwargs or 'default_sortdir' in kwargs:
sortkey = kwargs.pop('default_sortkey', None) sortkey = kwargs.pop('default_sortkey', None)
sortdir = kwargs.pop('default_sortdir', 'asc') sortdir = kwargs.pop('default_sortdir', 'asc')
if sortkey: if sortkey:
kwargs.setdefault('sort_defaults', [(sortkey, sortdir)]) kwargs.setdefault('sort_defaults', [(sortkey, sortdir)])
if kwargs.get('pageable'): if 'pageable' in kwargs:
warnings.warn("pageable param is deprecated for Grid(); " warnings.warn("pageable param is deprecated for Grid(); "
"please use vue_tagname param instead", "please use vue_tagname param instead",
DeprecationWarning, stacklevel=2) DeprecationWarning, stacklevel=2)
kwargs.setdefault('paginated', kwargs.pop('pageable')) kwargs.setdefault('paginated', kwargs.pop('pageable'))
if kwargs.get('default_pagesize'): if 'default_pagesize' in kwargs:
warnings.warn("default_pagesize param is deprecated for Grid(); " warnings.warn("default_pagesize param is deprecated for Grid(); "
"please use pagesize param instead", "please use pagesize param instead",
DeprecationWarning, stacklevel=2) DeprecationWarning, stacklevel=2)
kwargs.setdefault('pagesize', kwargs.pop('default_pagesize')) kwargs.setdefault('pagesize', kwargs.pop('default_pagesize'))
if kwargs.get('default_page'): if 'default_page' in kwargs:
warnings.warn("default_page param is deprecated for Grid(); " warnings.warn("default_page param is deprecated for Grid(); "
"please use page param instead", "please use page param instead",
DeprecationWarning, stacklevel=2) DeprecationWarning, stacklevel=2)

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar # Copyright © 2010-2024 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -24,9 +24,8 @@
Static Assets Static Assets
""" """
from __future__ import unicode_literals, absolute_import
def includeme(config): def includeme(config):
config.include('wuttaweb.static')
config.add_static_view('tailbone', 'tailbone:static') config.add_static_view('tailbone', 'tailbone:static')
config.add_static_view('deform', 'deform:static') config.add_static_view('deform', 'deform:static')

View file

@ -1,7 +1,7 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="/master/index.mako" /> <%inherit file="/master/index.mako" />
<%def name="render_grid_component()"> <%def name="page_content()">
<div class="buttons"> <div class="buttons">
@ -108,7 +108,7 @@
<div class="panel-block"> <div class="panel-block">
<div style="width: 100%;"> <div style="width: 100%;">
${parent.render_grid_component()} ${grid.render_vue_tag()}
</div> </div>
</div> </div>
</${b}-collapse> </${b}-collapse>

View file

@ -1,4 +1,5 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%namespace file="/wutta-components.mako" import="make_wutta_components" />
<%namespace file="/grids/nav.mako" import="grid_index_nav" /> <%namespace file="/grids/nav.mako" import="grid_index_nav" />
<%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" /> <%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" />
<%namespace name="base_meta" file="/base_meta.mako" /> <%namespace name="base_meta" file="/base_meta.mako" />
@ -955,6 +956,7 @@
</%def> </%def>
<%def name="make_vue_components()"> <%def name="make_vue_components()">
${make_wutta_components()}
${make_grid_filter_components()} ${make_grid_filter_components()}
${page_help.make_component()} ${page_help.make_component()}
${multi_file_upload.make_component()} ${multi_file_upload.make_component()}

View file

@ -43,7 +43,7 @@
<br /> <br />
<div class="form-wrapper"> <div class="form-wrapper">
<div class="form"> <div class="form">
<${execute_form.component} ref="executeResultsForm"></${execute_form.component}> ${execute_form.render_vue_tag(ref='executeResultsForm')}
</div> </div>
</div> </div>
</section> </section>
@ -67,7 +67,7 @@
<%def name="render_vue_templates()"> <%def name="render_vue_templates()">
${parent.render_vue_templates()} ${parent.render_vue_templates()}
% if master.results_executable and master.has_perm('execute_multiple'): % if master.results_executable and master.has_perm('execute_multiple'):
${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)}
% endif % endif
</%def> </%def>
@ -128,9 +128,6 @@
<%def name="make_vue_components()"> <%def name="make_vue_components()">
${parent.make_vue_components()} ${parent.make_vue_components()}
% if master.results_executable and master.has_perm('execute_multiple'): % if master.results_executable and master.has_perm('execute_multiple'):
<script> ${execute_form.render_vue_finalize()}
${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data }
Vue.component('${execute_form.vue_tagname}', ${execute_form.vue_component})
</script>
% endif % endif
</%def> </%def>

View file

@ -119,8 +119,7 @@
<div class="markdown"> <div class="markdown">
${execution_described|n} ${execution_described|n}
</div> </div>
<${execute_form.component} ref="executeBatchForm"> ${execute_form.render_vue_tag(ref='executeBatchForm')}
</${execute_form.component}>
</section> </section>
<footer class="modal-card-foot"> <footer class="modal-card-foot">
@ -168,8 +167,7 @@
Please be certain to use the right one! Please be certain to use the right one!
</p> </p>
<br /> <br />
<${upload_worksheet_form.component} ref="uploadForm"> ${upload_worksheet_form.render_vue_tag(ref='uploadForm')}
</${upload_worksheet_form.component}>
</section> </section>
<footer class="modal-card-foot"> <footer class="modal-card-foot">
@ -254,10 +252,10 @@
<%def name="render_vue_templates()"> <%def name="render_vue_templates()">
${parent.render_vue_templates()} ${parent.render_vue_templates()}
% if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'ref': 'actualUploadForm'})|n} ${upload_worksheet_form.render_vue_template(buttons=False, form_kwargs={'ref': 'actualUploadForm'})}
% endif % endif
% if master.handler.executable(batch) and master.has_perm('execute'): % if master.handler.executable(batch) and master.has_perm('execute'):
${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)}
% endif % endif
</%def> </%def>
@ -345,15 +343,9 @@
<%def name="make_vue_components()"> <%def name="make_vue_components()">
${parent.make_vue_components()} ${parent.make_vue_components()}
% if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
<script> ${upload_worksheet_form.render_vue_finalize()}
${upload_worksheet_form.vue_component}.data = function() { return ${upload_worksheet_form.vue_component}Data }
Vue.component('${upload_worksheet_form.component}', ${upload_worksheet_form.vue_component})
</script>
% endif % endif
% if execute_enabled and master.has_perm('execute'): % if execute_enabled and master.has_perm('execute'):
<script> ${execute_form.render_vue_finalize()}
${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data }
Vue.component('${execute_form.component}', ${execute_form.vue_component})
</script>
% endif % endif
</%def> </%def>

View file

@ -109,9 +109,6 @@
<%def name="make_vue_components()"> <%def name="make_vue_components()">
${parent.make_vue_components()} ${parent.make_vue_components()}
% if form is not Undefined: % if form is not Undefined:
<script> ${form.render_vue_finalize()}
${form.vue_component}.data = function() { return ${form.vue_component}Data }
Vue.component('${form.vue_tagname}', ${form.vue_component})
</script>
% endif % endif
</%def> </%def>

View file

@ -0,0 +1,486 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/base.mako" />
<%namespace name="base_meta" file="/base_meta.mako" />
<%namespace file="/formposter.mako" import="declare_formposter_mixin" />
<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" />
<%namespace name="page_help" file="/page_help.mako" />
<%def name="base_styles()">
${parent.base_styles()}
<style>
.filters .filter-fieldname .field,
.filters .filter-fieldname .field label {
width: 100%;
}
.filters .filter-fieldname,
.filters .filter-fieldname .field label,
.filters .filter-fieldname .button {
justify-content: left;
}
.filters .filter-verb .select,
.filters .filter-verb .select select {
width: 100%;
}
% if filter_fieldname_width is not Undefined:
.filters .filter-fieldname,
.filters .filter-fieldname .button {
min-width: ${filter_fieldname_width};
}
.filters .filter-verb {
min-width: ${filter_verb_width};
}
% endif
</style>
</%def>
<%def name="before_content()">
## TODO: this must come before the self.body() call..but why?
${declare_formposter_mixin()}
</%def>
<%def name="render_navbar_brand()">
<div class="navbar-brand">
<a class="navbar-item" href="${url('home')}"
v-show="!menuSearchActive">
${base_meta.header_logo()}
<div id="global-header-title">
${base_meta.global_title()}
</div>
</a>
<div v-show="menuSearchActive"
class="navbar-item">
<b-autocomplete ref="menuSearchAutocomplete"
v-model="menuSearchTerm"
:data="menuSearchFilteredData"
field="label"
open-on-focus
keep-first
icon-pack="fas"
clearable
@keydown.native="menuSearchKeydown"
@select="menuSearchSelect">
</b-autocomplete>
</div>
<a role="button" class="navbar-burger" data-target="navbar-menu" aria-label="menu" aria-expanded="false">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
</%def>
<%def name="render_navbar_start()">
<div class="navbar-start">
<div v-if="menuSearchData.length"
class="navbar-item">
<b-button type="is-primary"
size="is-small"
@click="menuSearchInit()">
<span><i class="fa fa-search"></i></span>
</b-button>
</div>
% for topitem in menus:
% if topitem['is_link']:
${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')}
% else:
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">${topitem['title']}</a>
<div class="navbar-dropdown">
% for item in topitem['items']:
% if item['is_menu']:
<% item_hash = id(item) %>
<% toggle = 'menu_{}_shown'.format(item_hash) %>
<div>
<a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')">
${item['title']}
</a>
</div>
% for subitem in item['items']:
% if subitem['is_sep']:
<hr class="navbar-divider" v-show="${toggle}">
% else:
${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})}
% endif
% endfor
% else:
% if item['is_sep']:
<hr class="navbar-divider">
% else:
${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])}
% endif
% endif
% endfor
</div>
</div>
% endif
% endfor
</div>
</%def>
<%def name="render_theme_picker()">
% if expose_theme_picker and request.has_perm('common.change_app_theme'):
<div class="level-item">
${h.form(url('change_theme'), method="post", ref='themePickerForm')}
${h.csrf_token(request)}
<input type="hidden" name="referrer" :value="referrer" />
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span>Theme:</span>
<b-select name="theme"
v-model="globalTheme"
@input="changeTheme()">
% for option in theme_picker_options:
<option value="${option.value}">
${option.label}
</option>
% endfor
</b-select>
</div>
${h.end_form()}
</div>
% endif
</%def>
<%def name="render_feedback_button()">
<div class="level-item">
<page-help
% if can_edit_help:
@configure-fields-help="configureFieldsHelp = true"
% endif
/>
</div>
% if request.has_perm('common.feedback'):
<feedback-form
action="${url('feedback')}"
:message="feedbackMessage">
</feedback-form>
% endif
</%def>
<%def name="render_this_page_component()">
<this-page @change-content-title="changeContentTitle"
% if can_edit_help:
:configure-fields-help="configureFieldsHelp"
% endif
/>
</%def>
<%def name="render_vue_templates()">
${parent.render_vue_templates()}
${page_help.render_template()}
${page_help.declare_vars()}
% if request.has_perm('common.feedback'):
<script type="text/x-template" id="feedback-template">
<div>
<div class="level-item">
<b-button type="is-primary"
@click="showFeedback()"
icon-pack="fas"
icon-left="comment">
Feedback
</b-button>
</div>
<b-modal has-modal-card
:active.sync="showDialog">
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">User Feedback</p>
</header>
<section class="modal-card-body">
<p class="block">
Questions, suggestions, comments, complaints, etc.
<span class="red">regarding this website</span> are
welcome and may be submitted below.
</p>
<b-field label="User Name">
<b-input v-model="userName"
% if request.user:
disabled
% endif
>
</b-input>
</b-field>
<b-field label="Referring URL">
<b-input
v-model="referrer"
disabled="true">
</b-input>
</b-field>
<b-field label="Message">
<b-input type="textarea"
v-model="message"
ref="textarea">
</b-input>
</b-field>
% if config.get_bool('tailbone.feedback_allows_reply'):
<div class="level">
<div class="level-left">
<div class="level-item">
<b-checkbox v-model="pleaseReply"
@input="pleaseReplyChanged">
Please email me back{{ pleaseReply ? " at: " : "" }}
</b-checkbox>
</div>
<div class="level-item" v-show="pleaseReply">
<b-input v-model="userEmail"
ref="userEmail">
</b-input>
</div>
</div>
</div>
% endif
</section>
<footer class="modal-card-foot">
<b-button @click="showDialog = false">
Cancel
</b-button>
<b-button type="is-primary"
icon-pack="fas"
icon-left="paper-plane"
@click="sendFeedback()"
:disabled="sendingFeedback || !message.trim()">
{{ sendingFeedback ? "Working, please wait..." : "Send Message" }}
</b-button>
</footer>
</div>
</b-modal>
</div>
</script>
<script>
const FeedbackForm = {
template: '#feedback-template',
mixins: [SimpleRequestMixin],
props: [
'action',
'message',
],
methods: {
showFeedback() {
this.referrer = location.href
this.showDialog = true
this.$nextTick(function() {
this.$refs.textarea.focus()
})
},
% if config.get_bool('tailbone.feedback_allows_reply'):
pleaseReplyChanged(value) {
this.$nextTick(() => {
this.$refs.userEmail.focus()
})
},
% endif
sendFeedback() {
this.sendingFeedback = true
const params = {
referrer: this.referrer,
user: this.userUUID,
user_name: this.userName,
% if config.get_bool('tailbone.feedback_allows_reply'):
please_reply_to: this.pleaseReply ? this.userEmail : null,
% endif
message: this.message.trim(),
}
this.simplePOST(this.action, params, response => {
this.$buefy.toast.open({
message: "Message sent! Thank you for your feedback.",
type: 'is-info',
duration: 4000, // 4 seconds
})
this.showDialog = false
// clear out message, in case they need to send another
this.message = ""
this.sendingFeedback = false
}, response => { // failure
this.sendingFeedback = false
})
},
}
}
const FeedbackFormData = {
referrer: null,
userUUID: null,
userName: null,
userEmail: null,
% if config.get_bool('tailbone.feedback_allows_reply'):
pleaseReply: false,
% endif
showDialog: false,
sendingFeedback: false,
}
</script>
% endif
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
##############################
## menu search
##############################
WholePageData.menuSearchActive = false
WholePageData.menuSearchTerm = ''
WholePageData.menuSearchData = ${json.dumps(global_search_data or [])|n}
WholePage.computed.menuSearchFilteredData = function() {
if (!this.menuSearchTerm.length) {
return this.menuSearchData
}
const terms = []
for (let term of this.menuSearchTerm.toLowerCase().split(' ')) {
term = term.trim()
if (term) {
terms.push(term)
}
}
if (!terms.length) {
return this.menuSearchData
}
// all terms must match
return this.menuSearchData.filter((option) => {
const label = option.label.toLowerCase()
for (const term of terms) {
if (label.indexOf(term) < 0) {
return false
}
}
return true
})
}
WholePage.methods.globalKey = function(event) {
// Ctrl+8 opens menu search
if (event.target.tagName == 'BODY') {
if (event.ctrlKey && event.key == '8') {
this.menuSearchInit()
}
}
}
WholePage.mounted = function() {
window.addEventListener('keydown', this.globalKey)
for (let hook of this.mountedHooks) {
hook(this)
}
}
WholePage.beforeDestroy = function() {
window.removeEventListener('keydown', this.globalKey)
}
WholePage.methods.menuSearchInit = function() {
this.menuSearchTerm = ''
this.menuSearchActive = true
this.$nextTick(() => {
this.$refs.menuSearchAutocomplete.focus()
})
}
WholePage.methods.menuSearchKeydown = function(event) {
// ESC will dismiss searchbox
if (event.which == 27) {
this.menuSearchActive = false
}
}
WholePage.methods.menuSearchSelect = function(option) {
location.href = option.url
}
##############################
## theme picker
##############################
% if expose_theme_picker and request.has_perm('common.change_app_theme'):
WholePageData.globalTheme = ${json.dumps(theme or None)|n}
## WholePageData.referrer = location.href
WholePage.methods.changeTheme = function() {
this.$refs.themePickerForm.submit()
}
% endif
##############################
## feedback
##############################
% if request.has_perm('common.feedback'):
WholePageData.feedbackMessage = ""
% if request.user:
FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n}
FeedbackFormData.userName = ${json.dumps(str(request.user))|n}
% endif
% endif
##############################
## edit fields help
##############################
% if can_edit_help:
WholePageData.configureFieldsHelp = false
% endif
</script>
</%def>
<%def name="make_vue_components()">
${parent.make_vue_components()}
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.datepicker.js') + f'?ver={tailbone.__version__}')}
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + f'?ver={tailbone.__version__}')}
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.js') + f'?ver={tailbone.__version__}')}
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + f'?ver={tailbone.__version__}')}
${make_grid_filter_components()}
${page_help.make_component()}
% if request.has_perm('common.feedback'):
<script>
FeedbackForm.data = function() { return FeedbackFormData }
Vue.component('feedback-form', FeedbackForm)
</script>
% endif
</%def>

View file

@ -0,0 +1,2 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/configure.mako" />

View file

@ -0,0 +1,2 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/form.mako" />

View file

@ -0,0 +1,2 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/master/configure.mako" />

View file

@ -0,0 +1,2 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/master/create.mako" />

View file

@ -0,0 +1,46 @@
## -*- coding: utf-8; -*-
<%inherit file="tailbone:templates/form.mako" />
<%def name="title()">Delete ${model_title}: ${instance_title}</%def>
<%def name="render_form()">
<br />
<b-notification type="is-danger" :closable="false">
You are about to delete the following ${model_title} and all associated data:
</b-notification>
${parent.render_form()}
</%def>
<%def name="render_form_buttons()">
<br />
<b-notification type="is-danger" :closable="false">
Are you sure about this?
</b-notification>
<br />
${h.form(request.current_route_url(), **{'@submit': 'submitForm'})}
${h.csrf_token(request)}
<div class="buttons">
<wutta-button once tag="a" href="${form.cancel_url}"
label="Whoops, nevermind..." />
<b-button type="is-primary is-danger"
native-type="submit"
:disabled="formSubmitting">
{{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }}
</b-button>
</div>
${h.end_form()}
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
${form.vue_component}Data.formSubmitting = false
${form.vue_component}.methods.submitForm = function() {
this.formSubmitting = true
}
</script>
</%def>

View file

@ -0,0 +1,2 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/master/edit.mako" />

View file

@ -0,0 +1,2 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/master/form.mako" />

View file

@ -0,0 +1,294 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/master/index.mako" />
<%def name="grid_tools()">
## grid totals
% if getattr(master, 'supports_grid_totals', False):
<div style="display: flex; align-items: center;">
<b-button v-if="gridTotalsDisplay == null"
:disabled="gridTotalsFetching"
@click="gridTotalsFetch()">
{{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }}
</b-button>
<div v-if="gridTotalsDisplay != null"
class="control">
Totals: {{ gridTotalsDisplay }}
</div>
</div>
% endif
## download search results
% if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'):
<div>
<b-button type="is-primary"
icon-pack="fas"
icon-left="download"
@click="showDownloadResultsDialog = true"
:disabled="!total">
Download Results
</b-button>
${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')}
${h.csrf_token(request)}
<input type="hidden" name="fmt" :value="downloadResultsFormat" />
<input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" />
${h.end_form()}
<b-modal :active.sync="showDownloadResultsDialog">
<div class="card">
<div class="card-content">
<p>
There are
<span class="is-size-4 has-text-weight-bold">
{{ total.toLocaleString('en') }} ${model_title_plural}
</span>
matching your current filters.
</p>
<p>
You may download this set as a single data file if you like.
</p>
<br />
<b-notification type="is-warning" :closable="false"
v-if="downloadResultsFormat == 'xlsx' && total >= 1000">
Excel downloads for large data sets can take a long time to
generate, and bog down the server in the meantime. You are
encouraged to choose CSV for a large data set, even though
the end result (file size) may be larger with CSV.
</b-notification>
<div style="display: flex; justify-content: space-between">
<div>
<b-field label="Format">
<b-select v-model="downloadResultsFormat">
% for key, label in master.download_results_supported_formats().items():
<option value="${key}">${label}</option>
% endfor
</b-select>
</b-field>
</div>
<div>
<div v-show="downloadResultsFieldsMode != 'choose'"
class="has-text-right">
<p v-if="downloadResultsFieldsMode == 'default'">
Will use DEFAULT fields.
</p>
<p v-if="downloadResultsFieldsMode == 'all'">
Will use ALL fields.
</p>
<br />
</div>
<div class="buttons is-right">
<b-button type="is-primary"
v-show="downloadResultsFieldsMode != 'default'"
@click="downloadResultsUseDefaultFields()">
Use Default Fields
</b-button>
<b-button type="is-primary"
v-show="downloadResultsFieldsMode != 'all'"
@click="downloadResultsUseAllFields()">
Use All Fields
</b-button>
<b-button type="is-primary"
v-show="downloadResultsFieldsMode != 'choose'"
@click="downloadResultsFieldsMode = 'choose'">
Choose Fields
</b-button>
</div>
<div v-show="downloadResultsFieldsMode == 'choose'">
<div style="display: flex;">
<div>
<b-field label="Excluded Fields">
<b-select multiple native-size="8"
expanded
v-model="downloadResultsExcludedFieldsSelected"
ref="downloadResultsExcludedFields">
<option v-for="field in downloadResultsFieldsExcluded"
:key="field"
:value="field">
{{ field }}
</option>
</b-select>
</b-field>
</div>
<div>
<br /><br />
<b-button style="margin: 0.5rem;"
@click="downloadResultsExcludeFields()">
&lt;
</b-button>
<br />
<b-button style="margin: 0.5rem;"
@click="downloadResultsIncludeFields()">
&gt;
</b-button>
</div>
<div>
<b-field label="Included Fields">
<b-select multiple native-size="8"
expanded
v-model="downloadResultsIncludedFieldsSelected"
ref="downloadResultsIncludedFields">
<option v-for="field in downloadResultsFieldsIncluded"
:key="field"
:value="field">
{{ field }}
</option>
</b-select>
</b-field>
</div>
</div>
</div>
</div>
</div>
</div> <!-- card-content -->
<footer class="modal-card-foot">
<b-button @click="showDownloadResultsDialog = false">
Cancel
</b-button>
<once-button type="is-primary"
@click="downloadResultsSubmit()"
icon-pack="fas"
icon-left="download"
:disabled="!downloadResultsFieldsIncluded.length"
text="Download Results">
</once-button>
</footer>
</div>
</b-modal>
</div>
% endif
## download rows for search results
% if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'):
<b-button type="is-primary"
icon-pack="fas"
icon-left="download"
@click="downloadResultsRows()"
:disabled="downloadResultsRowsButtonDisabled">
{{ downloadResultsRowsButtonText }}
</b-button>
${h.form(url('{}.download_results_rows'.format(route_prefix)), ref='downloadResultsRowsForm')}
${h.csrf_token(request)}
${h.end_form()}
% endif
## merge 2 objects
% if getattr(master, 'mergeable', False) and request.has_perm('{}.merge'.format(permission_prefix)):
${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})}
${h.csrf_token(request)}
<input type="hidden"
name="uuids"
:value="checkedRowUUIDs()" />
<b-button type="is-primary"
native-type="submit"
icon-pack="fas"
icon-left="object-ungroup"
:disabled="mergeFormSubmitting || checkedRows.length != 2">
{{ mergeFormButtonText }}
</b-button>
${h.end_form()}
% endif
## enable / disable selected objects
% if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'):
${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')}
${h.csrf_token(request)}
${h.hidden('uuids', v_model='selected_uuids')}
<b-button :disabled="enableSelectedDisabled"
@click="enableSelectedSubmit()">
{{ enableSelectedText }}
</b-button>
${h.end_form()}
${h.form(url('{}.disable_set'.format(route_prefix)), ref='disable_selected_form', class_='control')}
${h.csrf_token(request)}
${h.hidden('uuids', v_model='selected_uuids')}
<b-button :disabled="disableSelectedDisabled"
@click="disableSelectedSubmit()">
{{ disableSelectedText }}
</b-button>
${h.end_form()}
% endif
## delete selected objects
% if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'):
${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')}
${h.csrf_token(request)}
${h.hidden('uuids', v_model='selected_uuids')}
<b-button type="is-danger"
:disabled="deleteSelectedDisabled"
@click="deleteSelectedSubmit()"
icon-pack="fas"
icon-left="trash">
{{ deleteSelectedText }}
</b-button>
${h.end_form()}
% endif
## delete search results
% if getattr(master, 'bulk_deletable', False) and request.has_perm('{}.bulk_delete'.format(permission_prefix)):
${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')}
${h.csrf_token(request)}
<b-button type="is-danger"
:disabled="deleteResultsDisabled"
:title="total ? null : 'There are no results to delete'"
@click="deleteResultsSubmit()"
icon-pack="fas"
icon-left="trash">
{{ deleteResultsText }}
</b-button>
${h.end_form()}
% endif
</%def>
<%def name="render_vue_template_grid()">
${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())}
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
% if getattr(master, 'bulk_deletable', False) and master.has_perm('bulk_delete'):
${grid.vue_component}Data.deleteResultsSubmitting = false
${grid.vue_component}Data.deleteResultsText = "Delete Results"
${grid.vue_component}.computed.deleteResultsDisabled = function() {
if (this.deleteResultsSubmitting) {
return true
}
if (!this.total) {
return true
}
return false
}
${grid.vue_component}.methods.deleteResultsSubmit = function() {
// TODO: show "plural model title" here?
if (!confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) {
return
}
this.deleteResultsSubmitting = true
this.deleteResultsText = "Working, please wait..."
this.$refs.delete_results_form.submit()
}
% endif
</script>
</%def>

View file

@ -0,0 +1,2 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/master/view.mako" />

View file

@ -0,0 +1,48 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/page.mako" />
<%def name="render_vue_template_this_page()">
<script type="text/x-template" id="this-page-template">
<div style="height: 100%;">
## DEPRECATED; called for back-compat
${self.render_this_page()}
</div>
</script>
</%def>
## DEPRECATED; remains for back-compat
<%def name="render_this_page()">
<div style="display: flex;">
<div class="this-page-content" style="flex-grow: 1;">
${self.page_content()}
</div>
## DEPRECATED; remains for back-compat
<ul id="context-menu">
${self.context_menu_items()}
</ul>
</div>
</%def>
## DEPRECATED; remains for back-compat
<%def name="context_menu_items()">
% if context_menu_list_items is not Undefined:
% for item in context_menu_list_items:
<li>${item}</li>
% endfor
% endif
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
ThisPageData.csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}
% if can_edit_help:
ThisPage.props.configureFieldsHelp = Boolean
% endif
</script>
</%def>

View file

@ -137,6 +137,7 @@ class MasterView(View):
deleting = False deleting = False
executing = False executing = False
cloning = False cloning = False
configuring = False
has_pk_fields = False has_pk_fields = False
has_image = False has_image = False
has_thumbnail = False has_thumbnail = False
@ -350,6 +351,7 @@ class MasterView(View):
return self.json_response(context) return self.json_response(context)
context = { context = {
'index_url': None, # nb. avoid title link since this *is* the index
'grid': grid, 'grid': grid,
} }
@ -380,7 +382,7 @@ class MasterView(View):
grid contents etc. grid contents etc.
""" """
def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): def make_grid(self, factory=None, key=None, data=None, columns=None, session=None, **kwargs):
""" """
Creates a new grid instance Creates a new grid instance
""" """
@ -389,7 +391,7 @@ class MasterView(View):
if key is None: if key is None:
key = self.get_grid_key() key = self.get_grid_key()
if data is None: if data is None:
data = self.get_data(session=kwargs.get('session')) data = self.get_data(session=session)
if columns is None: if columns is None:
columns = self.get_grid_columns() columns = self.get_grid_columns()
@ -407,7 +409,7 @@ class MasterView(View):
""" """
if session is None: if session is None:
session = self.Session() session = self.Session()
kwargs.setdefault('pageable', False) kwargs.setdefault('paginated', False)
grid = self.make_grid(session=session, **kwargs) grid = self.make_grid(session=session, **kwargs)
return grid.make_visible_data() return grid.make_visible_data()
@ -1701,7 +1703,7 @@ class MasterView(View):
""" """
if session is None: if session is None:
session = self.Session() session = self.Session()
kwargs.setdefault('pageable', False) kwargs.setdefault('paginated', False)
kwargs.setdefault('sortable', sort) kwargs.setdefault('sortable', sort)
grid = self.make_row_grid(session=session, **kwargs) grid = self.make_row_grid(session=session, **kwargs)
return grid.make_visible_data() return grid.make_visible_data()
@ -1879,6 +1881,7 @@ class MasterView(View):
return self.redirect(self.get_action_url('view', instance)) return self.redirect(self.get_action_url('view', instance))
form = self.make_form(instance) form = self.make_form(instance)
form.save_label = "DELETE Forever"
# TODO: Add better validation, ideally CSRF etc. # TODO: Add better validation, ideally CSRF etc.
if self.request.method == 'POST': if self.request.method == 'POST':
@ -5119,6 +5122,7 @@ class MasterView(View):
""" """
Generic view for configuring some aspect of the software. Generic view for configuring some aspect of the software.
""" """
self.configuring = True
app = self.get_rattail_app() app = self.get_rattail_app()
if self.request.method == 'POST': if self.request.method == 'POST':
if self.request.POST.get('remove_settings'): if self.request.POST.get('remove_settings'):

View file

@ -543,7 +543,7 @@ class PersonView(MasterView):
}, },
filterable=True, filterable=True,
sortable=True, sortable=True,
pageable=True, paginated=True,
default_sortkey='end_time', default_sortkey='end_time',
default_sortdir='desc', default_sortdir='desc',
component='transactions-grid', component='transactions-grid',

View file

@ -24,7 +24,7 @@ class WebTestCase(DataTestCase):
self.pyramid_config = testing.setUp(request=self.request, settings={ self.pyramid_config = testing.setUp(request=self.request, settings={
'wutta_config': self.config, 'wutta_config': self.config,
'rattail_config': self.config, 'rattail_config': self.config,
'mako.directories': ['tailbone:templates'], 'mako.directories': ['tailbone:templates', 'wuttaweb:templates'],
# 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform', # 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform',
}) })