Add support for Buefy autocomplete; several other form tweaks

at least the Edit User form should work now, for instance
This commit is contained in:
Lance Edgar 2019-06-08 13:46:00 -05:00
parent d7e19865de
commit 2b6d88105c
16 changed files with 390 additions and 42 deletions

View file

@ -760,6 +760,29 @@ class Form(object):
context['render_field_readonly'] = self.render_field_readonly context['render_field_readonly'] = self.render_field_readonly
return render(template, context) return render(template, context)
def get_vuejs_model_value(self, field):
"""
This method must return "raw" JS which will be assigned as the initial
model value for the given field. This JS will be written as part of
the overall response, to be interpreted on the client side.
"""
if isinstance(field.schema.typ, colander.Date):
# TODO: don't recall why "always null" here?
return 'null'
if isinstance(field.schema.typ, deform.FileData):
# TODO: don't recall why "always null" here?
return 'null'
if isinstance(field.schema.typ, colander.Set):
if field.cstruct is colander.null:
return '[]'
if field.cstruct is colander.null:
return 'null'
return json.dumps(field.cstruct)
def messages_json(self, messages): def messages_json(self, messages):
dump = json.dumps(messages) dump = json.dumps(messages)
dump = dump.replace("'", ''') dump = dump.replace("'", ''')

View file

@ -0,0 +1,91 @@
const TailboneAutocomplete = {
template: '#tailbone-autocomplete-template',
props: {
name: String,
serviceUrl: String,
value: String,
initialLabel: String,
},
data() {
let selected = null
if (this.value) {
selected = {
value: this.value,
label: this.initialLabel,
}
}
return {
data: [],
selected: selected,
isFetching: false,
autocompleteValue: this.value,
}
},
methods: {
clearSelection() {
this.selected = null
this.autocompleteValue = null
this.$nextTick(function() {
this.$refs.autocomplete.focus()
})
// TODO: should emit event for caller logic (can they cancel?)
// $('#' + oid + '-textbox').trigger('autocompletevaluecleared');
},
// TODO: should we allow custom callback? or is event enough?
// function (oid) {
// $('#' + oid + '-textbox').on('autocompletevaluecleared', function() {
// ${cleared_callback}();
// });
// }
selectionMade(option) {
this.selected = option
// TODO: should emit event for caller logic (can they cancel?)
// $('#' + oid + '-textbox').trigger('autocompletevalueselected',
// [ui.item.value, ui.item.label]);
},
// TODO: should we allow custom callback? or is event enough?
// function (oid) {
// $('#' + oid + '-textbox').on('autocompletevalueselected', function(event, uuid, label) {
// ${selected_callback}(uuid, label);
// });
// }
itemSelected(value) {
this.$emit('input', value)
},
// TODO: buefy example uses `debounce()` here and perhaps we should too?
// https://buefy.org/documentation/autocomplete
getAsyncData: function (entry) {
if (entry.length < 3) {
this.data = []
return
}
this.isFetching = true
this.$http.get(this.serviceUrl + '?term=' + encodeURIComponent(entry))
.then(({ data }) => {
this.data = data
})
.catch((error) => {
this.data = []
throw error
})
.finally(() => {
this.isFetching = false
})
},
},
}
Vue.component('tailbone-autocomplete', TailboneAutocomplete)

View file

@ -10,6 +10,8 @@ const OnceButton = {
':title="title"', ':title="title"',
':disabled="buttonDisabled"', ':disabled="buttonDisabled"',
'@click="clicked"', '@click="clicked"',
'icon-pack="fas"',
':icon-left="iconLeft"',
'>', '>',
'{{ buttonText }}', '{{ buttonText }}',
'</b-button>' '</b-button>'
@ -22,6 +24,7 @@ const OnceButton = {
href: String, href: String,
text: String, text: String,
title: String, title: String,
iconLeft: String,
working: String, working: String,
workingText: String, workingText: String,
disabled: Boolean disabled: Boolean

View file

@ -13,3 +13,12 @@
white-space: nowrap; white-space: nowrap;
width: 18em; width: 18em;
} }
.field.is-horizontal .field-body {
min-width: 30em;
}
.field.is-horizontal .field-body .select,
.field.is-horizontal .field-body .select select {
width: 100%;
}

View file

@ -1,4 +1,5 @@
## -*- coding: utf-8 -*- ## -*- coding: utf-8; -*-
## TODO: This function signature is getting out of hand... ## TODO: This function signature is getting out of hand...
<%def name="autocomplete(field_name, service_url, field_value=None, field_display=None, width='300px', select=None, selected=None, cleared=None, change_clicked=None, options={})"> <%def name="autocomplete(field_name, service_url, field_value=None, field_display=None, width='300px', select=None, selected=None, cleared=None, change_clicked=None, options={})">
<div id="${field_name}-container" class="autocomplete-container"> <div id="${field_name}-container" class="autocomplete-container">
@ -56,3 +57,31 @@
}); });
</script> </script>
</%def> </%def>
<%def name="tailbone_autocomplete_template()">
<script type="text/x-template" id="tailbone-autocomplete-template">
<div>
<b-autocomplete ref="autocomplete"
:name="name"
v-show="!selected"
v-model="autocompleteValue"
:data="data"
@typing="getAsyncData"
@select="selectionMade"
@input="itemSelected"
keep-first>
<template slot-scope="props">
{{ props.option.label }}
</template>
</b-autocomplete>
<b-button v-if="selected"
style="width: 100%; justify-content: left;"
@click="clearSelection()">
{{ selected.label }} (click to change)
</b-button>
</div>
</script>
</%def>

View file

@ -2,10 +2,14 @@
css_class css_class|field.widget.css_class; css_class css_class|field.widget.css_class;
oid oid|field.oid; oid oid|field.oid;
field_display field_display; field_display field_display;
style style|field.widget.style" style style|field.widget.style;
id="${oid}-container" url url|field.widget.service_url;
class="autocomplete-container"> use_buefy use_buefy|0;"
tal:omit-tag="">
<div tal:condition="not use_buefy"
id="${oid}-container"
class="autocomplete-container">
<input type="hidden" <input type="hidden"
name="${name}" name="${name}"
id="${oid}" id="${oid}"
@ -98,5 +102,16 @@
} }
); );
</script> </script>
</div>
<div tal:condition="use_buefy"
tal:define="vmodel vmodel|'field_model_' + name;"
tal:omit-tag="">
<tailbone-autocomplete name="${name}"
service-url="${url}"
v-model="${vmodel}"
initial-label="${field_display}">
</tailbone-autocomplete>
</div>
</div> </div>

View file

@ -1,13 +1,26 @@
<div class="checkbox"> <div tal:define="name name|field.name;
<input tal:define="name name|field.name; true_val true_val|field.widget.true_val;
true_val true_val|field.widget.true_val; css_class css_class|field.widget.css_class;
css_class css_class|field.widget.css_class; style style|field.widget.style;
style style|field.widget.style; oid oid|field.oid;
oid oid|field.oid" use_buefy use_buefy|0;"
type="checkbox" tal:omit-tag="">
<div tal:condition="not use_buefy" class="checkbox">
<input type="checkbox"
name="${name}" value="${true_val}" name="${name}" value="${true_val}"
id="${oid}" id="${oid}"
tal:attributes="checked cstruct == true_val; tal:attributes="checked cstruct == true_val;
class css_class; class css_class;
style style;" /> style style;" />
</div>
<div tal:condition="use_buefy"
tal:define="vmodel vmodel|'field_model_' + name;">
<b-checkbox name="${name}"
v-model="${vmodel}"
native-value="${true_val}">
{{ ${vmodel} }}
</b-checkbox>
</div>
</div> </div>

View file

@ -0,0 +1,60 @@
<div i18n:domain="deform" tal:omit-tag=""
tal:define="oid oid|field.oid;
name name|field.name;
css_class css_class|field.widget.css_class;
style style|field.widget.style;
use_buefy use_buefy|0;">
<div tal:condition="not use_buefy" tal:omit-tag="">
${field.start_mapping()}
<div>
<input type="password"
name="${name}"
value="${field.widget.redisplay and cstruct or ''}"
tal:attributes="class string: form-control ${css_class or ''};
style style;
attributes|field.widget.attributes|{};"
id="${oid}"
i18n:attributes="placeholder"
placeholder="Password"/>
</div>
<div>
<input type="password"
name="${name}-confirm"
value="${field.widget.redisplay and confirm or ''}"
tal:attributes="class string: form-control ${css_class or ''};
style style;
confirm_attributes|field.widget.confirm_attributes|{};"
id="${oid}-confirm"
i18n:attributes="placeholder"
placeholder="Confirm Password"/>
</div>
${field.end_mapping()}
</div>
<div tal:condition="use_buefy">
${field.start_mapping()}
<b-input type="password"
name="${name}"
value="${field.widget.redisplay and cstruct or ''}"
tal:attributes="class string: form-control ${css_class or ''};
style style;
attributes|field.widget.attributes|{};"
id="${oid}"
i18n:attributes="placeholder"
placeholder="Password">
</b-input>
<b-input type="password"
name="${name}-confirm"
value="${field.widget.redisplay and confirm or ''}"
tal:attributes="class string: form-control ${css_class or ''};
style style;
confirm_attributes|field.widget.confirm_attributes|{};"
id="${oid}-confirm"
i18n:attributes="placeholder"
placeholder="Confirm Password">
</b-input>
${field.end_mapping()}
</div>
</div>

View file

@ -58,12 +58,14 @@
<div tal:condition="use_buefy" <div tal:condition="use_buefy"
tal:define="vmodel vmodel|'field_model_' + name;" tal:define="vmodel vmodel|'field_model_' + name;"
tal:omit-tag=""> >
<input type="hidden" name="__start__" value="${name}:sequence"
tal:condition="multiple" />
<b-select tal:attributes="name name; <b-select tal:attributes="name name;
id oid; id oid;
placeholder '(please choose)'; placeholder '(please choose)';
class string: form-control ${css_class or ''}; class string: form-control ${css_class or ''};
multiple multiple; :multiple str(multiple).lower();
size size; size size;
style style; style style;
v-model vmodel; v-model vmodel;
@ -88,6 +90,8 @@
</tal:loop> </tal:loop>
</b-select> </b-select>
<input type="hidden" name="__end__" value="${name}:sequence"
tal:condition="multiple" />
</div> </div>
</div> </div>

View file

@ -0,0 +1,27 @@
<div tal:define="rows rows|field.widget.rows;
cols cols|field.widget.cols;
css_class css_class|field.widget.css_class;
oid oid|field.oid;
name name|field.name;
style style|field.widget.style;
use_buefy use_buefy|0;"
tal:omit-tag="">
<div tal:condition="not use_buefy" tal:omit-tag="">
<textarea tal:attributes="rows rows;
cols cols;
class string: form-control ${css_class or ''};
style style;
attributes|field.widget.attributes|{};"
id="${oid}"
name="${name}">${cstruct}</textarea>
</div>
<div tal:condition="use_buefy"
tal:define="vmodel vmodel|'field_model_' + name;">
<b-input type="textarea"
name="${name}"
v-model="${vmodel}">
</b-input>
</div>
</div>

View file

@ -0,0 +1,32 @@
<span tal:define="name name|field.name;
css_class css_class|field.widget.css_class;
oid oid|field.oid;
mask mask|field.widget.mask;
mask_placeholder mask_placeholder|field.widget.mask_placeholder;
style style|field.widget.style;
use_buefy use_buefy|0;"
tal:omit-tag="">
<div tal:condition="not use_buefy" tal:omit-tag="">
<input type="text" name="${name}" value="${cstruct}"
tal:attributes="class string: form-control ${css_class or ''};
style style;
attributes|field.widget.attributes|{};"
id="${oid}"/>
<script tal:condition="mask" type="text/javascript">
deform.addCallback(
'${oid}',
function (oid) {
$("#" + oid).mask("${mask}",
{placeholder:"${mask_placeholder}"});
});
</script>
</div>
<div tal:condition="use_buefy"
tal:define="vmodel vmodel|'field_model_' + name;"
tal:omit-tag="">
<b-input name="${name}"
v-model="${vmodel}">
</b-input>
</div>
</span>

View file

@ -11,7 +11,38 @@
${form.render(buttons=capture(self.render_form_buttons))|n} ${form.render(buttons=capture(self.render_form_buttons))|n}
</%def> </%def>
<%def name="render_buefy_form()">
<div class="form">
<tailbone-form></tailbone-form>
</div>
</%def>
<%def name="render_form_complete()"> <%def name="render_form_complete()">
% if use_buefy:
${self.render_form()}
<script type="text/x-template" id="form-page-template">
<div style="display: flex; justify-content: space-between;">
<div class="form-wrapper">
${self.render_buefy_form()}
</div>
<div style="display: flex; align-items: flex-start;">
<div class="object-helpers">
${self.object_helpers()}
</div>
<ul id="context-menu">
${self.context_menu_items()}
</ul>
</div>
</div>
</script>
<div id="form-page-app">
<form-page></form-page>
</div>
% else:
<div style="display: flex; justify-content: space-between;"> <div style="display: flex; justify-content: space-between;">
<div class="form-wrapper"> <div class="form-wrapper">
@ -29,6 +60,7 @@
</div> </div>
</div> </div>
% endif
</%def> </%def>
<%def name="modify_tailbone_form()"> <%def name="modify_tailbone_form()">
@ -43,8 +75,14 @@
Vue.component('tailbone-form', TailboneForm) Vue.component('tailbone-form', TailboneForm)
const FormPage = {
template: '#form-page-template'
}
Vue.component('form-page', FormPage)
new Vue({ new Vue({
el: '#tailbone-form-app' el: '#form-page-app'
}) })
</script> </script>
@ -53,6 +91,6 @@
${self.render_form_complete()} ${self.render_form_complete()}
% if form.use_buefy: % if use_buefy:
${self.make_tailbone_form_app()} ${self.make_tailbone_form_app()}
% endif % endif

View file

@ -91,21 +91,10 @@
% for field in form.fields: % for field in form.fields:
% if field in dform: % if field in dform:
<% field = dform[field] %> <% field = dform[field] %>
% if isinstance(field.schema.typ, colander.Date): field_model_${field.name}: ${form.get_vuejs_model_value(field)|n},
field_model_${field.name}: null,
% elif isinstance(field.schema.typ, deform.FileData):
field_model_${field.name}: null,
% else:
field_model_${field.name}: ${'null' if field.cstruct is colander.null else json.dumps(field.cstruct)|n},
% endif
% endif % endif
% endfor % endfor
% endif % endif
} }
</script> </script>
<div id="tailbone-form-app">
<tailbone-form></tailbone-form>
</div>

View file

@ -20,16 +20,28 @@
% endif % endif
</%def> </%def>
<%def name="render_form()"> <%def name="render_buefy_form()">
<br /> <br />
% if use_buefy:
<b-notification type="is-danger" :closable="false">
You are about to delete the following ${model_title} and all associated data:
</b-notification>
% else:
<p>You are about to delete the following ${model_title} and all associated data:</p> <p>You are about to delete the following ${model_title} and all associated data:</p>
% endif
${parent.render_form()} ${parent.render_buefy_form()}
</%def> </%def>
<%def name="render_form_buttons()"> <%def name="render_form_buttons()">
<br /> <br />
% if use_buefy:
<b-notification type="is-danger" :closable="false">
Are you sure about this?
</b-notification>
% else:
<p>Are you sure about this?</p> <p>Are you sure about this?</p>
% endif
<br /> <br />
${h.form(request.current_route_url(), class_=None if form.use_buefy else 'autodisable')} ${h.form(request.current_route_url(), class_=None if form.use_buefy else 'autodisable')}
@ -39,15 +51,13 @@
<once-button tag="a" href="${form.cancel_url}" <once-button tag="a" href="${form.cancel_url}"
text="Whoops, nevermind..."> text="Whoops, nevermind...">
</once-button> </once-button>
% else: <once-button type="is-primary is-danger"
<a class="button" href="${form.cancel_url}">Whoops, nevermind...</a> native-type="submit"
% endif
% if form.use_buefy:
<once-button type="is-primary" native-type="submit"
text="Yes, please DELETE this data forever!"> text="Yes, please DELETE this data forever!">
</once-button> </once-button>
% else: % else:
${h.submit('submit', "Yes, please DELETE this data forever!", class_='button is-primary')} <a class="button" href="${form.cancel_url}">Whoops, nevermind...</a>
${h.submit('submit', "Yes, please DELETE this data forever!", class_='button is-primary')}
% endif % endif
</div> </div>
${h.end_form()} ${h.end_form()}

View file

@ -141,13 +141,12 @@
<input type="hidden" <input type="hidden"
name="uuids" name="uuids"
:value="checkedRowUUIDs()" /> :value="checkedRowUUIDs()" />
<b-button type="is-primary" <once-button type="is-primary"
native-type="submit" native-type="submit"
icon-pack="fas" icon-left="object-ungroup"
icon-left="object-ungroup" :disabled="checkedRows.length != 2"
:disabled="checkedRows.length != 2"> text="Merge 2 ${model_title_plural}">
Merge 2 ${model_title_plural} </once-button>
</b-button>
% else: % else:
${h.hidden('uuids')} ${h.hidden('uuids')}
<button type="submit" class="button">Merge 2 ${model_title_plural}</button> <button type="submit" class="button">Merge 2 ${model_title_plural}</button>

View file

@ -1,6 +1,7 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%namespace file="/grids/nav.mako" import="grid_index_nav" /> <%namespace file="/grids/nav.mako" import="grid_index_nav" />
<%namespace file="/feedback_dialog_buefy.mako" import="feedback_dialog" /> <%namespace file="/feedback_dialog_buefy.mako" import="feedback_dialog" />
<%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" />
<%namespace name="base_meta" file="/base_meta.mako" /> <%namespace name="base_meta" file="/base_meta.mako" />
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@ -30,6 +31,11 @@
</head> </head>
<body> <body>
## TODO: should move template to JS, then can postpone the JS
${tailbone_autocomplete_template()}
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))}
<header> <header>
<nav class="navbar" role="navigation" aria-label="main navigation"> <nav class="navbar" role="navigation" aria-label="main navigation">