3
0
Fork 0

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:
Lance Edgar 2025-12-23 22:40:33 -06:00
parent 9edf6f298c
commit 49c001c9ad
9 changed files with 300 additions and 109 deletions

View file

@ -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 "&nbsp;")
# render field label
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

View file

@ -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:

View 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

View file

@ -0,0 +1,4 @@
## -*- coding: utf-8; -*-
% for fieldname in form:
${form.render_vue_field(fieldname, horizontal=True)}
% endfor

View file

@ -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:

View file

@ -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",
},
)

View file

@ -0,0 +1,9 @@
## -*- coding: utf-8; -*-
<%inherit file="/form.mako" />
<%def name="page_content()">
RANDOM TEXT
${parent.page_content()}
</%def>

View 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>

View file

@ -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()