feat: overhaul how form vue template is rendered
now a page template can add `<%def name="form_vue_fields()">` and the form should inspect/discover and use that instead of its default
This commit is contained in:
parent
9edf6f298c
commit
49c001c9ad
9 changed files with 300 additions and 109 deletions
|
|
@ -275,6 +275,10 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth
|
|||
deform_form = None
|
||||
validated = None
|
||||
|
||||
vue_template = "/forms/vue_template.mako"
|
||||
fields_template = "/forms/vue_fields.mako"
|
||||
buttons_template = "/forms/vue_buttons.mako"
|
||||
|
||||
def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
|
||||
self,
|
||||
request,
|
||||
|
|
@ -331,6 +335,7 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth
|
|||
self.show_button_cancel = show_button_cancel
|
||||
self.button_label_cancel = button_label_cancel
|
||||
self.auto_disable_cancel = auto_disable_cancel
|
||||
self.form_attrs = {}
|
||||
|
||||
self.config = self.request.wutta_config
|
||||
self.app = self.config.get_app()
|
||||
|
|
@ -806,7 +811,10 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth
|
|||
# get fields
|
||||
fields = self.get_fields()
|
||||
if not fields:
|
||||
raise NotImplementedError
|
||||
raise ValueError(
|
||||
"could not determine fields list; "
|
||||
"please set model_class or fields explicitly"
|
||||
)
|
||||
|
||||
if self.model_class:
|
||||
|
||||
|
|
@ -939,7 +947,7 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth
|
|||
"""
|
||||
return HTML.tag(self.vue_tagname, **kwargs)
|
||||
|
||||
def render_vue_template(self, template="/forms/vue_template.mako", **context):
|
||||
def render_vue_template(self, template=None, **context):
|
||||
"""
|
||||
Render the Vue template block for the form.
|
||||
|
||||
|
|
@ -954,8 +962,8 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth
|
|||
</script>
|
||||
|
||||
<script>
|
||||
WuttaFormData = {}
|
||||
WuttaForm = {
|
||||
const WuttaFormData = {}
|
||||
const WuttaForm = {
|
||||
template: 'wutta-form-template',
|
||||
}
|
||||
</script>
|
||||
|
|
@ -969,36 +977,121 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth
|
|||
Actual output will of course depend on form attributes, i.e.
|
||||
:attr:`vue_tagname` and :attr:`fields` list etc.
|
||||
|
||||
:param template: Path to Mako template which is used to render
|
||||
the output.
|
||||
Default logic will also invoke (indirectly):
|
||||
|
||||
* :meth:`render_vue_fields()`
|
||||
* :meth:`render_vue_buttons()`
|
||||
|
||||
:param template: Optional template path to override the class
|
||||
default.
|
||||
|
||||
:returns: HTML literal
|
||||
"""
|
||||
context = self.get_vue_context(**context)
|
||||
html = render(template or self.vue_template, context)
|
||||
return HTML.literal(html)
|
||||
|
||||
def get_vue_context(self, **context): # pylint: disable=missing-function-docstring
|
||||
context["form"] = self
|
||||
context["dform"] = self.get_deform()
|
||||
context.setdefault("request", self.request)
|
||||
context["model_data"] = self.get_vue_model_data()
|
||||
|
||||
# set form method, enctype
|
||||
context.setdefault("form_attrs", {})
|
||||
context["form_attrs"].setdefault("method", self.action_method)
|
||||
form_attrs = context.setdefault("form_attrs", dict(self.form_attrs))
|
||||
form_attrs.setdefault("method", self.action_method)
|
||||
if self.action_method == "post":
|
||||
context["form_attrs"].setdefault("enctype", "multipart/form-data")
|
||||
form_attrs.setdefault("enctype", "multipart/form-data")
|
||||
|
||||
# auto disable button on submit
|
||||
if self.auto_disable_submit:
|
||||
context["form_attrs"]["@submit"] = "formSubmitting = true"
|
||||
form_attrs["@submit"] = "formSubmitting = true"
|
||||
|
||||
output = render(template, context)
|
||||
return HTML.literal(output)
|
||||
# duplicate entire context for sake of fields/buttons template
|
||||
context["form_context"] = context
|
||||
|
||||
return context
|
||||
|
||||
def render_vue_fields(self, context, template=None, **kwargs):
|
||||
"""
|
||||
Render the fields section within the form template.
|
||||
|
||||
This is normally invoked from within the form's
|
||||
``vue_template`` like this:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
${form.render_vue_fields(form_context)}
|
||||
|
||||
There is a default ``fields_template`` but that is only the
|
||||
last resort. Logic will first look for a
|
||||
``form_vue_fields()`` def within the *main template* being
|
||||
rendered for the page.
|
||||
|
||||
An example will surely help:
|
||||
|
||||
.. code-block:: mako
|
||||
|
||||
<%inherit file="/master/edit.mako" />
|
||||
|
||||
<%def name="form_vue_fields()">
|
||||
|
||||
<p>this is my custom fields section:</p>
|
||||
|
||||
${form.render_vue_field("myfield")}
|
||||
|
||||
</%def>
|
||||
|
||||
This keeps the custom fields section within the main page
|
||||
template as opposed to yet another file. But if your page
|
||||
template has no ``form_vue_fields()`` def, then the class
|
||||
default template is used. (Unless the ``template`` param
|
||||
is specified.)
|
||||
|
||||
See also :meth:`render_vue_template()` and
|
||||
:meth:`render_vue_buttons()`.
|
||||
|
||||
:param context: This must be the original context as provided
|
||||
to the form's ``vue_template``. See example above.
|
||||
|
||||
:param template: Optional template path to use instead of the
|
||||
defaults described above.
|
||||
|
||||
:returns: HTML literal
|
||||
"""
|
||||
context.update(kwargs)
|
||||
html = False
|
||||
|
||||
if not template:
|
||||
|
||||
if main_template := context.get("main_template"):
|
||||
try:
|
||||
vue_fields = main_template.get_def("form_vue_fields")
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
html = vue_fields.render(**context)
|
||||
|
||||
if html is False:
|
||||
template = self.fields_template
|
||||
|
||||
if html is False:
|
||||
html = render(template, context)
|
||||
|
||||
return HTML.literal(html)
|
||||
|
||||
def render_vue_field( # pylint: disable=unused-argument,too-many-locals
|
||||
self,
|
||||
fieldname,
|
||||
readonly=None,
|
||||
label=True,
|
||||
horizontal=True,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Render the given field completely, i.e. ``<b-field>`` wrapper
|
||||
with label and containing a widget.
|
||||
with label and a widget, with validation errors flagged as
|
||||
needed.
|
||||
|
||||
Actual output will depend on the field attributes etc.
|
||||
Typical output might look like:
|
||||
|
|
@ -1009,14 +1102,23 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth
|
|||
horizontal
|
||||
type="is-danger"
|
||||
message="something went wrong!">
|
||||
<!-- widget element(s) -->
|
||||
<b-input name="foo"
|
||||
v-model="${form.get_field_vmodel('foo')}" />
|
||||
</b-field>
|
||||
|
||||
.. warning::
|
||||
:param fieldname: Name of field to render.
|
||||
|
||||
Any ``**kwargs`` received from caller are ignored by this
|
||||
method. For now they are allowed, for sake of backwawrd
|
||||
compatibility. This may change in the future.
|
||||
:param readonly: Optional override for readonly flag.
|
||||
|
||||
:param label: Whether to include/set the field label.
|
||||
|
||||
:param horizontal: Boolean value for the ``horizontal`` flag
|
||||
on the field.
|
||||
|
||||
:param \\**kwargs: Remaining kwargs are passed to widget's
|
||||
``serialize()`` method.
|
||||
|
||||
:returns: HTML literal
|
||||
"""
|
||||
# readonly comes from: caller, field flag, or form flag
|
||||
if readonly is None:
|
||||
|
|
@ -1034,10 +1136,9 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth
|
|||
|
||||
# render proper widget if field is in deform/schema
|
||||
field = dform[fieldname]
|
||||
kw = {}
|
||||
if readonly:
|
||||
kw["readonly"] = True
|
||||
html = field.serialize(**kw)
|
||||
kwargs["readonly"] = True
|
||||
html = field.serialize(**kwargs)
|
||||
|
||||
else:
|
||||
# render static text if field not in deform/schema
|
||||
|
|
@ -1052,12 +1153,13 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth
|
|||
html = HTML.literal(html or " ")
|
||||
|
||||
# render field label
|
||||
label = self.get_label(fieldname)
|
||||
if label:
|
||||
label = self.get_label(fieldname)
|
||||
|
||||
# b-field attrs
|
||||
attrs = {
|
||||
":horizontal": "true",
|
||||
"label": label,
|
||||
":horizontal": "true" if horizontal else "false",
|
||||
"label": label or "",
|
||||
}
|
||||
|
||||
# next we will build array of messages to display..some
|
||||
|
|
@ -1085,6 +1187,36 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth
|
|||
|
||||
return HTML.tag("b-field", c=[html], **attrs)
|
||||
|
||||
def render_vue_buttons(self, context, template=None, **kwargs):
|
||||
"""
|
||||
Render the buttons section within the form template.
|
||||
|
||||
This is normally invoked from within the form's
|
||||
``vue_template`` like this:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
${form.render_vue_buttons(form_context)}
|
||||
|
||||
.. note::
|
||||
|
||||
This method does not yet inspect the main page template,
|
||||
unlike :meth:`render_vue_fields()`.
|
||||
|
||||
See also :meth:`render_vue_template()`.
|
||||
|
||||
:param context: This must be the original context as provided
|
||||
to the form's ``vue_template``. See example above.
|
||||
|
||||
:param template: Optional template path to override the class
|
||||
default.
|
||||
|
||||
:returns: HTML literal
|
||||
"""
|
||||
context.update(kwargs)
|
||||
html = render(template or self.buttons_template, context)
|
||||
return HTML.literal(html)
|
||||
|
||||
def render_vue_finalize(self):
|
||||
"""
|
||||
Render the Vue "finalize" script for the form.
|
||||
|
|
@ -1103,6 +1235,25 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth
|
|||
"""
|
||||
return render_vue_finalize(self.vue_tagname, self.vue_component)
|
||||
|
||||
def get_field_vmodel(self, field):
|
||||
"""
|
||||
Convenience to return the ``v-model`` data reference for the
|
||||
given field. For instance:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
<b-input name="myfield"
|
||||
v-model="${form.get_field_vmodel('myfield')}" />
|
||||
|
||||
<div v-show="${form.get_field_vmodel('myfield')} == 'easter'">
|
||||
easter egg!
|
||||
</div>
|
||||
|
||||
:returns: JS-valid string referencing the field value
|
||||
"""
|
||||
dform = self.get_deform()
|
||||
return f"modelData.{dform[field].oid}"
|
||||
|
||||
def get_vue_model_data(self):
|
||||
"""
|
||||
Returns a dict with form model data. Values may be nested
|
||||
|
|
|
|||
|
|
@ -34,17 +34,19 @@
|
|||
|
||||
<%def name="tool_panels()"></%def>
|
||||
|
||||
<%def name="render_vue_template_form()">
|
||||
% if form is not Undefined:
|
||||
${form.render_vue_template()}
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="render_vue_templates()">
|
||||
${parent.render_vue_templates()}
|
||||
${self.render_vue_template_form()}
|
||||
</%def>
|
||||
|
||||
<%def name="render_vue_template_form()">
|
||||
% if form is not Undefined:
|
||||
## nb. must provide main template to form, so it can
|
||||
## do 'def' lookup as needed for fields template etc.
|
||||
${form.render_vue_template(main_template=self.template)}
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="make_vue_components()">
|
||||
${parent.make_vue_components()}
|
||||
% if form is not Undefined:
|
||||
|
|
|
|||
43
src/wuttaweb/templates/forms/vue_buttons.mako
Normal file
43
src/wuttaweb/templates/forms/vue_buttons.mako
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
% if not form.readonly:
|
||||
<br />
|
||||
<div class="buttons"
|
||||
% if form.align_buttons_right:
|
||||
style="justify-content: right;"
|
||||
% endif
|
||||
>
|
||||
|
||||
% if form.show_button_cancel:
|
||||
<wutta-button ${'once' if form.auto_disable_cancel else ''}
|
||||
tag="a" href="${form.get_cancel_url()}"
|
||||
label="${form.button_label_cancel}" />
|
||||
% endif
|
||||
|
||||
% if form.show_button_reset:
|
||||
<b-button
|
||||
% if form.reset_url:
|
||||
tag="a" href="${form.reset_url}"
|
||||
% else:
|
||||
native-type="reset"
|
||||
% endif
|
||||
>
|
||||
Reset
|
||||
</b-button>
|
||||
% endif
|
||||
|
||||
<b-button type="${form.button_type_submit}"
|
||||
native-type="submit"
|
||||
% if form.auto_disable_submit:
|
||||
:disabled="formSubmitting"
|
||||
% endif
|
||||
icon-pack="fas"
|
||||
icon-left="${form.button_icon_submit}">
|
||||
% if form.auto_disable_submit:
|
||||
{{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }}
|
||||
% else:
|
||||
${form.button_label_submit}
|
||||
% endif
|
||||
</b-button>
|
||||
|
||||
</div>
|
||||
% endif
|
||||
4
src/wuttaweb/templates/forms/vue_fields.mako
Normal file
4
src/wuttaweb/templates/forms/vue_fields.mako
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
% for fieldname in form:
|
||||
${form.render_vue_field(fieldname, horizontal=True)}
|
||||
% endfor
|
||||
|
|
@ -14,66 +14,21 @@
|
|||
% endfor
|
||||
% endif
|
||||
|
||||
<section>
|
||||
% for fieldname in form:
|
||||
${form.render_vue_field(fieldname)}
|
||||
% endfor
|
||||
</section>
|
||||
${form.render_vue_fields(form_context)}
|
||||
|
||||
% if not form.readonly:
|
||||
<br />
|
||||
<div class="buttons"
|
||||
% if form.align_buttons_right:
|
||||
style="justify-content: right;"
|
||||
% endif
|
||||
>
|
||||
|
||||
% if form.show_button_cancel:
|
||||
<wutta-button ${'once' if form.auto_disable_cancel else ''}
|
||||
tag="a" href="${form.get_cancel_url()}"
|
||||
label="${form.button_label_cancel}" />
|
||||
% endif
|
||||
|
||||
% if form.show_button_reset:
|
||||
<b-button
|
||||
% if form.reset_url:
|
||||
tag="a" href="${form.reset_url}"
|
||||
% else:
|
||||
native-type="reset"
|
||||
% endif
|
||||
>
|
||||
Reset
|
||||
</b-button>
|
||||
% endif
|
||||
|
||||
<b-button type="${form.button_type_submit}"
|
||||
native-type="submit"
|
||||
% if form.auto_disable_submit:
|
||||
:disabled="formSubmitting"
|
||||
% endif
|
||||
icon-pack="fas"
|
||||
icon-left="${form.button_icon_submit}">
|
||||
% if form.auto_disable_submit:
|
||||
{{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }}
|
||||
% else:
|
||||
${form.button_label_submit}
|
||||
% endif
|
||||
</b-button>
|
||||
|
||||
</div>
|
||||
% endif
|
||||
${form.render_vue_buttons(form_context)}
|
||||
|
||||
${h.end_form()}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
|
||||
let ${form.vue_component} = {
|
||||
const ${form.vue_component} = {
|
||||
template: '#${form.vue_tagname}-template',
|
||||
methods: {},
|
||||
}
|
||||
|
||||
let ${form.vue_component}Data = {
|
||||
const ${form.vue_component}Data = {
|
||||
|
||||
% if not form.readonly:
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ class WebTestCase(DataTestCase):
|
|||
Base class for test suites requiring a full (typical) web app.
|
||||
"""
|
||||
|
||||
mako_directories = ["wuttaweb:templates"]
|
||||
|
||||
def setUp(self): # pylint: disable=empty-docstring
|
||||
""" """
|
||||
self.setup_web()
|
||||
|
|
@ -57,7 +59,7 @@ class WebTestCase(DataTestCase):
|
|||
request=self.request,
|
||||
settings={
|
||||
"wutta_config": self.config,
|
||||
"mako.directories": ["wuttaweb:templates"],
|
||||
"mako.directories": self.mako_directories,
|
||||
"pyramid_deform.template_search_path": "wuttaweb:templates/deform",
|
||||
},
|
||||
)
|
||||
|
|
|
|||
9
tests/forms/main_template.mako
Normal file
9
tests/forms/main_template.mako
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
<%inherit file="/form.mako" />
|
||||
|
||||
<%def name="page_content()">
|
||||
|
||||
RANDOM TEXT
|
||||
|
||||
${parent.page_content()}
|
||||
</%def>
|
||||
11
tests/forms/main_template_with_fields.mako
Normal file
11
tests/forms/main_template_with_fields.mako
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
<%inherit file="/form.mako" />
|
||||
|
||||
<%def name="form_vue_fields()">
|
||||
|
||||
SOMETHING CRAZY
|
||||
|
||||
<b-field label="name">
|
||||
<b-input name="name" v-model="${form.get_field_vmodel('name')}" />
|
||||
</b-field>
|
||||
</%def>
|
||||
|
|
@ -1,48 +1,28 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
from unittest import TestCase
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
import colander
|
||||
import deform
|
||||
from pyramid import testing
|
||||
from pyramid.renderers import get_renderer
|
||||
|
||||
from wuttjamaican.conf import WuttaConfig
|
||||
from wuttaweb.forms import base, widgets
|
||||
from wuttaweb import helpers, subscribers
|
||||
from wuttaweb.forms import base as mod, widgets
|
||||
from wuttaweb.grids import Grid
|
||||
from wuttaweb.testing import WebTestCase
|
||||
|
||||
|
||||
class TestForm(TestCase):
|
||||
here = os.path.dirname(__file__)
|
||||
|
||||
def setUp(self):
|
||||
self.config = WuttaConfig(
|
||||
defaults={
|
||||
"wutta.web.menus.handler_spec": "tests.util:NullMenuHandler",
|
||||
}
|
||||
)
|
||||
self.app = self.config.get_app()
|
||||
self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False)
|
||||
|
||||
self.pyramid_config = testing.setUp(
|
||||
request=self.request,
|
||||
settings={
|
||||
"wutta_config": self.config,
|
||||
"mako.directories": ["wuttaweb:templates"],
|
||||
"pyramid_deform.template_search_path": "wuttaweb:templates/deform",
|
||||
},
|
||||
)
|
||||
class TestForm(WebTestCase):
|
||||
|
||||
event = MagicMock(request=self.request)
|
||||
subscribers.new_request(event)
|
||||
|
||||
def tearDown(self):
|
||||
testing.tearDown()
|
||||
mako_directories = ["wuttaweb:templates", here]
|
||||
|
||||
def make_form(self, **kwargs):
|
||||
return base.Form(self.request, **kwargs)
|
||||
return mod.Form(self.request, **kwargs)
|
||||
|
||||
def make_schema(self):
|
||||
schema = colander.Schema(
|
||||
|
|
@ -279,7 +259,7 @@ class TestForm(TestCase):
|
|||
# but auto-generating without fields is not supported
|
||||
form = self.make_form()
|
||||
self.assertIsNone(form.schema)
|
||||
self.assertRaises(NotImplementedError, form.get_schema)
|
||||
self.assertRaises(ValueError, form.get_schema)
|
||||
|
||||
# schema is auto-generated if model_class provided
|
||||
form = self.make_form(model_class=model.Setting)
|
||||
|
|
@ -541,6 +521,40 @@ class TestForm(TestCase):
|
|||
self.assertIn("<script>", html)
|
||||
self.assertIn("Vue.component('wutta-form', WuttaForm)", html)
|
||||
|
||||
def test_get_field_vmodel(self):
|
||||
model = self.app.model
|
||||
form = self.make_form(model_class=model.Setting)
|
||||
result = form.get_field_vmodel("name")
|
||||
self.assertEqual(result, "modelData.deformField1")
|
||||
|
||||
def test_render_vue_fields(self):
|
||||
model = self.app.model
|
||||
form = self.make_form(model_class=model.Setting)
|
||||
context = form.get_vue_context()
|
||||
|
||||
# standard behavior
|
||||
html = form.render_vue_fields(context)
|
||||
self.assertIn("<b-field", html)
|
||||
self.assertNotIn("SOMETHING CRAZY", html)
|
||||
self.assertNotIn("RANDOM TEXT", html)
|
||||
|
||||
# declare main template, so form will look for the fields def
|
||||
# (but this template has no def)
|
||||
template = get_renderer("/main_template.mako").template
|
||||
with patch.dict(context, {"main_template": template}):
|
||||
html = form.render_vue_fields(context)
|
||||
self.assertIn("<b-field", html)
|
||||
self.assertNotIn("SOMETHING CRAZY", html)
|
||||
self.assertNotIn("RANDOM TEXT", html)
|
||||
|
||||
# now use a main template which has the fields def
|
||||
template = get_renderer("/main_template_with_fields.mako").template
|
||||
with patch.dict(context, {"main_template": template}):
|
||||
html = form.render_vue_fields(context)
|
||||
self.assertIn("<b-field", html)
|
||||
self.assertIn("SOMETHING CRAZY", html)
|
||||
self.assertNotIn("RANDOM TEXT", html)
|
||||
|
||||
def test_render_vue_field(self):
|
||||
self.pyramid_config.include("pyramid_deform")
|
||||
schema = self.make_schema()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue