1
0
Fork 0

feat: add basic Edit support for CRUD master view

This commit is contained in:
Lance Edgar 2024-08-10 21:07:38 -05:00
parent 9e1fc6e57d
commit 1a8fc8dd44
12 changed files with 640 additions and 51 deletions

View file

@ -149,10 +149,35 @@ class Form:
Default for this is ``False`` in which case the ``<form>`` tag Default for this is ``False`` in which case the ``<form>`` tag
will exist and submit is allowed. will exist and submit is allowed.
.. attribute:: readonly_fields
Set of fields which should be readonly. Each will still be
rendered but with static value text and no widget.
This is only applicable if :attr:`readonly` is ``False``.
See also :meth:`set_readonly()`.
.. attribute:: action_url .. attribute:: action_url
String URL to which the form should be submitted, if applicable. String URL to which the form should be submitted, if applicable.
.. attribute:: cancel_url
String URL to which the Cancel button should "always" redirect,
if applicable.
Code should not access this directly, but instead call
:meth:`get_cancel_url()`.
.. attribute:: cancel_url_fallback
String URL to which the Cancel button should redirect, if
referrer cannot be determined from request.
Code should not access this directly, but instead call
:meth:`get_cancel_url()`.
.. attribute:: vue_tagname .. attribute:: vue_tagname
String name for Vue component tag. By default this is String name for Vue component tag. By default this is
@ -180,6 +205,23 @@ class Form:
.. attribute:: show_button_reset .. attribute:: show_button_reset
Flag indicating whether a Reset button should be shown. Flag indicating whether a Reset button should be shown.
Default is ``False``.
.. attribute:: show_button_cancel
Flag indicating whether a Cancel button should be shown.
Default is ``True``.
.. attribute:: button_label_cancel
String label for the form cancel button. Default is
``"Cancel"``.
.. attribute:: auto_disable_cancel
Flag indicating whether the cancel button should be
auto-disabled, whenever the button is clicked. Default is
``True``.
.. attribute:: validated .. attribute:: validated
@ -197,26 +239,38 @@ class Form:
model_class=None, model_class=None,
model_instance=None, model_instance=None,
readonly=False, readonly=False,
readonly_fields=[],
labels={}, labels={},
action_url=None, action_url=None,
cancel_url=None,
cancel_url_fallback=None,
vue_tagname='wutta-form', vue_tagname='wutta-form',
align_buttons_right=False, align_buttons_right=False,
auto_disable_submit=True, auto_disable_submit=True,
button_label_submit="Save", button_label_submit="Save",
button_icon_submit='save', button_icon_submit='save',
show_button_reset=False, show_button_reset=False,
show_button_cancel=True,
button_label_cancel="Cancel",
auto_disable_cancel=True,
): ):
self.request = request self.request = request
self.schema = schema self.schema = schema
self.readonly = readonly self.readonly = readonly
self.readonly_fields = set(readonly_fields or [])
self.labels = labels or {} self.labels = labels or {}
self.action_url = action_url self.action_url = action_url
self.cancel_url = cancel_url
self.cancel_url_fallback = cancel_url_fallback
self.vue_tagname = vue_tagname self.vue_tagname = vue_tagname
self.align_buttons_right = align_buttons_right self.align_buttons_right = align_buttons_right
self.auto_disable_submit = auto_disable_submit self.auto_disable_submit = auto_disable_submit
self.button_label_submit = button_label_submit self.button_label_submit = button_label_submit
self.button_icon_submit = button_icon_submit self.button_icon_submit = button_icon_submit
self.show_button_reset = show_button_reset self.show_button_reset = show_button_reset
self.show_button_cancel = show_button_cancel
self.button_label_cancel = button_label_cancel
self.auto_disable_cancel = auto_disable_cancel
self.config = self.request.wutta_config self.config = self.request.wutta_config
self.app = self.config.get_app() self.app = self.config.get_app()
@ -262,6 +316,39 @@ class Form:
words = self.vue_tagname.split('-') words = self.vue_tagname.split('-')
return ''.join([word.capitalize() for word in words]) return ''.join([word.capitalize() for word in words])
def get_cancel_url(self):
"""
Returns the URL for the Cancel button.
If :attr:`cancel_url` is set, its value is returned.
Or, if the referrer can be deduced from the request, that is
returned.
Or, if :attr:`cancel_url_fallback` is set, that value is
returned.
As a last resort the "default" URL from
:func:`~wuttaweb.subscribers.request.get_referrer()` is
returned.
"""
# use "permanent" URL if set
if self.cancel_url:
return self.cancel_url
# nb. use fake default to avoid normal default logic;
# that way if we get something it's a real referrer
url = self.request.get_referrer(default='NOPE')
if url and url != 'NOPE':
return url
# use fallback URL if set
if self.cancel_url_fallback:
return self.cancel_url_fallback
# okay, home page then (or whatever is the default URL)
return self.request.get_referrer()
def set_fields(self, fields): def set_fields(self, fields):
""" """
Explicitly set the list of form fields. Explicitly set the list of form fields.
@ -273,6 +360,41 @@ class Form:
""" """
self.fields = FieldList(fields) self.fields = FieldList(fields)
def set_readonly(self, key, readonly=True):
"""
Enable or disable the "readonly" flag for a given field.
When a field is marked readonly, it will be shown in the form
but there will be no editable widget. The field is skipped
over (not saved) when form is submitted.
See also :meth:`is_readonly()`; this is tracked via
:attr:`readonly_fields`.
:param key: String key (fieldname) for the field.
:param readonly: New readonly flag for the field.
"""
if readonly:
self.readonly_fields.add(key)
else:
if key in self.readonly_fields:
self.readonly_fields.remove(key)
def is_readonly(self, key):
"""
Returns boolean indicating if the given field is marked as
readonly.
See also :meth:`set_readonly()`.
:param key: Field key/name as string.
"""
if self.readonly_fields:
if key in self.readonly_fields:
return True
return False
def set_label(self, key, label): def set_label(self, key, label):
""" """
Set the label for given field name. Set the label for given field name.
@ -381,6 +503,7 @@ class Form:
the output. the output.
""" """
context['form'] = self context['form'] = self
context['dform'] = self.get_deform()
context.setdefault('form_attrs', {}) context.setdefault('form_attrs', {})
context.setdefault('request', self.request) context.setdefault('request', self.request)
@ -408,17 +531,34 @@ class Form:
<!-- widget element(s) --> <!-- widget element(s) -->
</b-field> </b-field>
""" """
# readonly comes from: caller, field flag, or form flag
if readonly is None: if readonly is None:
readonly = self.readonly readonly = self.is_readonly(fieldname)
if not readonly:
readonly = self.readonly
# but also, fields not in deform/schema must be readonly
dform = self.get_deform()
if not readonly and fieldname not in dform:
readonly = True
# render the field widget or whatever # render the field widget or whatever
dform = self.get_deform() if fieldname in dform:
field = dform[fieldname]
kw = {} # render proper widget if field is in deform/schema
if readonly: field = dform[fieldname]
kw['readonly'] = True kw = {}
html = field.serialize(**kw) if readonly:
kw['readonly'] = True
html = field.serialize(**kw)
else:
# render static text if field not in deform/schema
# TODO: need to abstract this somehow
if self.model_instance:
html = str(self.model_instance[fieldname])
else:
html = ''
# mark all that as safe # mark all that as safe
html = HTML.literal(html) html = HTML.literal(html)

View file

@ -64,7 +64,7 @@ def new_request(event):
Reference to the app :term:`config object`. Reference to the app :term:`config object`.
.. method:: request.get_referrer(default=None) .. function:: request.get_referrer(default=None)
Request method to get the "canonical" HTTP referrer value. Request method to get the "canonical" HTTP referrer value.
This has logic to check for referrer in the request params, This has logic to check for referrer in the request params,

View file

@ -1,5 +1,6 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%namespace name="base_meta" file="/base_meta.mako" /> <%namespace name="base_meta" file="/base_meta.mako" />
<%namespace file="/wutta-components.mako" import="make_wutta_components" />
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -366,7 +367,21 @@
${self.render_prevnext_header_buttons()} ${self.render_prevnext_header_buttons()}
</%def> </%def>
<%def name="render_crud_header_buttons()"></%def> <%def name="render_crud_header_buttons()">
% if master:
% if master.viewing:
<wutta-button once
tag="a" href="${master.get_action_url('edit', instance)}"
icon-left="edit"
label="Edit This" />
% elif master.editing:
<wutta-button once
tag="a" href="${master.get_action_url('view', instance)}"
icon-left="eye"
label="View This" />
% endif
% endif
</%def>
<%def name="render_prevnext_header_buttons()"></%def> <%def name="render_prevnext_header_buttons()"></%def>
@ -432,6 +447,7 @@
<%def name="finalize_whole_page_vars()"></%def> <%def name="finalize_whole_page_vars()"></%def>
<%def name="make_whole_page_component()"> <%def name="make_whole_page_component()">
${make_wutta_components()}
${self.render_whole_page_template()} ${self.render_whole_page_template()}
${self.declare_whole_page_vars()} ${self.declare_whole_page_vars()}
${self.modify_whole_page_vars()} ${self.modify_whole_page_vars()}

View file

@ -13,6 +13,12 @@
% if not form.readonly: % if not form.readonly:
<div style="margin-top: 1.5rem; display: flex; gap: 0.5rem; justify-content: ${'end' if form.align_buttons_right else 'start'}; width: 100%; padding-left: 10rem;"> <div style="margin-top: 1.5rem; display: flex; gap: 0.5rem; justify-content: ${'end' if form.align_buttons_right else 'start'}; width: 100%; padding-left: 10rem;">
% 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: % if form.show_button_reset:
<b-button native-type="reset"> <b-button native-type="reset">
Reset Reset
@ -50,7 +56,9 @@
## field model values ## field model values
% for key in form: % for key in form:
model_${key}: ${form.get_vue_field_value(key)|n}, % if key in dform:
model_${key}: ${form.get_vue_field_value(key)|n},
% endif
% endfor % endfor
% if form.auto_disable_submit: % if form.auto_disable_submit:

View file

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

View file

@ -0,0 +1,71 @@
<%def name="make_wutta_components()">
${self.make_wutta_button_component()}
</%def>
<%def name="make_wutta_button_component()">
<script type="text/x-template" id="wutta-button-template">
<b-button :type="type"
:native-type="nativeType"
:tag="tag"
:href="href"
:title="title"
:disabled="buttonDisabled"
@click="clicked"
icon-pack="fas"
:icon-left="iconLeft">
{{ buttonLabel }}
</b-button>
</script>
<script>
const WuttaButton = {
template: '#wutta-button-template',
props: {
type: String,
nativeType: String,
tag: String,
href: String,
label: String,
title: String,
iconLeft: String,
working: String,
workingLabel: String,
disabled: Boolean,
once: Boolean,
},
data() {
return {
currentLabel: null,
currentDisabled: null,
}
},
computed: {
buttonLabel: function() {
return this.currentLabel || this.label
},
buttonDisabled: function() {
if (this.currentDisabled !== null) {
return this.currentDisabled
}
return this.disabled
},
},
methods: {
clicked(event) {
if (this.once) {
this.currentDisabled = true
if (this.workingLabel) {
this.currentLabel = this.workingLabel
} else if (this.working) {
this.currentLabel = this.working + ", please wait..."
} else {
this.currentLabel = "Working, please wait..."
}
}
}
},
}
Vue.component('wutta-button', WuttaButton)
</script>
</%def>

View file

@ -59,13 +59,11 @@ class AuthView(View):
form = self.make_form(schema=self.login_make_schema(), form = self.make_form(schema=self.login_make_schema(),
align_buttons_right=True, align_buttons_right=True,
show_button_cancel=False,
show_button_reset=True, show_button_reset=True,
button_label_submit="Login", button_label_submit="Login",
button_icon_submit='user') button_icon_submit='user')
# TODO
# form.show_cancel = False
# validate basic form data (sanity check) # validate basic form data (sanity check)
data = form.validate() data = form.validate()
if data: if data:
@ -155,6 +153,7 @@ class AuthView(View):
return self.redirect(self.request.route_url('home')) return self.redirect(self.request.route_url('home'))
form = self.make_form(schema=self.change_password_make_schema(), form = self.make_form(schema=self.change_password_make_schema(),
show_button_cancel=False,
show_button_reset=True) show_button_reset=True)
data = form.validate() data = form.validate()

View file

@ -174,6 +174,12 @@ class MasterView(View):
i.e. it should have a :meth:`view()` view. Default value is i.e. it should have a :meth:`view()` view. Default value is
``True``. ``True``.
.. attribute:: editable
Boolean indicating whether the view model supports "editing" -
i.e. it should have an :meth:`edit()` view. Default value is
``True``.
.. attribute:: form_fields .. attribute:: form_fields
List of columns for the model form. List of columns for the model form.
@ -195,11 +201,13 @@ class MasterView(View):
listable = True listable = True
has_grid = True has_grid = True
viewable = True viewable = True
editable = True
configurable = False configurable = False
# current action # current action
listing = False listing = False
viewing = False viewing = False
editing = False
configuring = False configuring = False
############################## ##############################
@ -260,10 +268,15 @@ class MasterView(View):
actions = [] actions = []
# TODO: should split this off into index_get_grid_actions() ? # TODO: should split this off into index_get_grid_actions() ?
if self.viewable: if self.viewable:
actions.append(self.make_grid_action('view', icon='eye', actions.append(self.make_grid_action('view', icon='eye',
url=self.get_action_url_view)) url=self.get_action_url_view))
if self.editable:
actions.append(self.make_grid_action('edit', icon='edit',
url=self.get_action_url_edit))
kwargs['actions'] = actions kwargs['actions'] = actions
grid = self.make_grid(**kwargs) grid = self.make_grid(**kwargs)
@ -309,21 +322,6 @@ class MasterView(View):
""" """
return [] return []
def get_action_url_view(self, obj, i):
"""
Returns the "view" grid action URL for the given object.
Most typically this is like ``/widgets/XXX`` where ``XXX``
represents the object's key/ID.
"""
route_prefix = self.get_route_prefix()
kw = {}
for key in self.get_model_key():
kw[key] = obj[key]
return self.request.route_url(f'{route_prefix}.view', **kw)
############################## ##############################
# view methods # view methods
############################## ##############################
@ -341,9 +339,12 @@ class MasterView(View):
The default view logic will show a read-only form with field The default view logic will show a read-only form with field
values displayed. values displayed.
See also related methods, which are called by this one: Subclass normally should not override this method, but rather
one of the related methods which are called (in)directly by
this one:
* :meth:`make_model_form()` * :meth:`make_model_form()`
* :meth:`configure_form()`
""" """
self.viewing = True self.viewing = True
instance = self.get_instance() instance = self.get_instance()
@ -356,6 +357,71 @@ class MasterView(View):
} }
return self.render_to_response('view', context) return self.render_to_response('view', context)
##############################
# edit methods
##############################
def edit(self):
"""
View to "edit" details of an existing model record.
This usually corresponds to a URL like ``/widgets/XXX/edit``
where ``XXX`` represents the key/ID for the record.
By default, this view is included only if :attr:`editable` is
true.
The default "edit" view logic will show a form with field
widgets, allowing user to modify and submit new values which
are then persisted to the DB (assuming typical SQLAlchemy
model).
Subclass normally should not override this method, but rather
one of the related methods which are called (in)directly by
this one:
* :meth:`make_model_form()`
* :meth:`configure_form()`
* :meth:`edit_save_form()`
"""
self.editing = True
instance = self.get_instance()
instance_title = self.get_instance_title(instance)
form = self.make_model_form(instance,
cancel_url_fallback=self.get_index_url())
if self.request.method == 'POST':
if form.validate():
self.edit_save_form(form)
return self.redirect(self.get_action_url('view', instance))
context = {
'instance': instance,
'instance_title': instance_title,
'form': form,
}
return self.render_to_response('edit', context)
def edit_save_form(self, form):
"""
This method is responsible for "converting" the validated form
data to a model instance, and then "saving" the result,
e.g. to DB.
Subclass may override this, or any of the related methods
called by this one:
* :meth:`objectify()`
* :meth:`persist()`
:returns: This should return the resulting model instance,
which was produced by :meth:`objectify()`.
"""
obj = self.objectify(form)
self.persist(obj)
return obj
############################## ##############################
# configure methods # configure methods
############################## ##############################
@ -785,6 +851,49 @@ class MasterView(View):
""" """
return str(instance) return str(instance)
def get_action_url(self, action, obj, **kwargs):
"""
Generate an "action" URL for the given model instance.
This is a shortcut which generates a route name based on
:meth:`get_route_prefix()` and the ``action`` param.
It returns the URL based on generated route name and object's
model key values.
:param action: String name for the action, which corresponds
to part of some named route, e.g. ``'view'`` or ``'edit'``.
:param obj: Model instance object.
"""
route_prefix = self.get_route_prefix()
kw = dict([(key, obj[key])
for key in self.get_model_key()])
kw.update(kwargs)
return self.request.route_url(f'{route_prefix}.{action}', **kw)
def get_action_url_view(self, obj, i):
"""
Returns the "view" grid action URL for the given object.
Most typically this is like ``/widgets/XXX`` where ``XXX``
represents the object's key/ID.
Calls :meth:`get_action_url()` under the hood.
"""
return self.get_action_url('view', obj)
def get_action_url_edit(self, obj, i):
"""
Returns the "edit" grid action URL for the given object.
Most typically this is like ``/widgets/XXX/edit`` where
``XXX`` represents the object's key/ID.
Calls :meth:`get_action_url()` under the hood.
"""
return self.get_action_url('edit', obj)
def make_model_form(self, model_instance=None, **kwargs): def make_model_form(self, model_instance=None, **kwargs):
""" """
Create and return a :class:`~wuttaweb.forms.base.Form` Create and return a :class:`~wuttaweb.forms.base.Form`
@ -794,6 +903,7 @@ class MasterView(View):
e.g.: e.g.:
* :meth:`view()` * :meth:`view()`
* :meth:`edit()`
See also related methods, which are called by this one: See also related methods, which are called by this one:
@ -837,12 +947,58 @@ class MasterView(View):
Configure the given model form, as needed. Configure the given model form, as needed.
This is called by :meth:`make_model_form()` - for multiple This is called by :meth:`make_model_form()` - for multiple
CRUD views. CRUD views (create, view, edit, delete, possibly others).
There is no default logic here; subclass should override if The default logic here does just one thing: when "editing"
needed. The ``form`` param will already be "complete" and (i.e. in :meth:`edit()` view) then all fields which are part
ready to use as-is, but this method can further modify it of the :attr:`model_key` will be marked via
based on request details etc. :meth:`set_readonly()` so the user cannot change primary key
values for a record.
Subclass may override as needed. The ``form`` param will
already be "complete" and ready to use as-is, but this method
can further modify it based on request details etc.
"""
if self.editing:
for key in self.get_model_key():
form.set_readonly(key)
def objectify(self, form):
"""
Must return a "model instance" object which reflects the
validated form data.
In simple cases this may just return the
:attr:`~wuttaweb.forms.base.Form.validated` data dict.
When dealing with SQLAlchemy models it would return a proper
mapped instance, creating it if necessary.
:param form: Reference to the *already validated*
:class:`~wuttaweb.forms.base.Form` object. See the form's
:attr:`~wuttaweb.forms.base.Form.validated` attribute for
the data.
See also :meth:`edit_save_form()` which calls this method.
"""
return form.validated
def persist(self, obj):
"""
If applicable, this method should persist ("save") the given
object's data (e.g. to DB), creating or updating it as needed.
This is part of the "submit form" workflow; ``obj`` should be
a model instance which already reflects the validated form
data.
Note that there is no default logic here, subclass must
override if needed.
:param obj: Model instance object as produced by
:meth:`objectify()`.
See also :meth:`edit_save_form()` which calls this method.
""" """
############################## ##############################
@ -1145,6 +1301,14 @@ class MasterView(View):
config.add_view(cls, attr='view', config.add_view(cls, attr='view',
route_name=f'{route_prefix}.view') route_name=f'{route_prefix}.view')
# edit
if cls.editable:
instance_url_prefix = cls.get_instance_url_prefix()
config.add_route(f'{route_prefix}.edit',
f'{instance_url_prefix}/edit')
config.add_view(cls, attr='edit',
route_name=f'{route_prefix}.edit')
# configure # configure
if cls.configurable: if cls.configurable:
config.add_route(f'{route_prefix}.configure', config.add_route(f'{route_prefix}.configure',

View file

@ -197,6 +197,36 @@ class SettingView(MasterView):
""" """ """ """
return setting['name'] return setting['name']
def make_model_form(self, *args, **kwargs):
""" """
# TODO: sheesh what a hack. hopefully not needed for long..
# here we ensure deform is created before introducing our
# `name` field, to keep it out of submit handling
if self.editing:
kwargs['fields'] = ['value']
form = super().make_model_form(*args, **kwargs)
if self.editing:
form.get_deform()
form.fields.insert_before('value', 'name')
return form
def configure_form(self, f):
""" """
super().configure_form(f)
if self.editing:
f.set_readonly('name')
def persist(self, setting, session=None):
""" """
name = self.get_instance(session=session)['name']
session = session or Session()
self.app.save_setting(session, name, setting['value'])
def defaults(config, **kwargs): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
from unittest import TestCase from unittest import TestCase
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
import colander import colander
import deform import deform
@ -151,6 +151,34 @@ class TestForm(TestCase):
dform = form.get_deform() dform = form.get_deform()
self.assertEqual(dform.cstruct, myobj) self.assertEqual(dform.cstruct, myobj)
def test_get_cancel_url(self):
# is referrer by default
form = self.make_form()
self.request.get_referrer = MagicMock(return_value='/cancel-default')
self.assertEqual(form.get_cancel_url(), '/cancel-default')
del self.request.get_referrer
# or can be static URL
form = self.make_form(cancel_url='/cancel-static')
self.assertEqual(form.get_cancel_url(), '/cancel-static')
# or can be fallback URL (nb. 'NOPE' indicates no referrer)
form = self.make_form(cancel_url_fallback='/cancel-fallback')
self.request.get_referrer = MagicMock(return_value='NOPE')
self.assertEqual(form.get_cancel_url(), '/cancel-fallback')
del self.request.get_referrer
# or can be referrer fallback, i.e. home page
form = self.make_form()
def get_referrer(default=None):
if default == 'NOPE':
return 'NOPE'
return '/home-page'
self.request.get_referrer = get_referrer
self.assertEqual(form.get_cancel_url(), '/home-page')
del self.request.get_referrer
def test_get_label(self): def test_get_label(self):
form = self.make_form(fields=['foo', 'bar']) form = self.make_form(fields=['foo', 'bar'])
self.assertEqual(form.get_label('foo'), "Foo") self.assertEqual(form.get_label('foo'), "Foo")
@ -170,6 +198,26 @@ class TestForm(TestCase):
self.assertEqual(form.get_label('foo'), "Woohoo") self.assertEqual(form.get_label('foo'), "Woohoo")
self.assertEqual(schema['foo'].title, "Woohoo") self.assertEqual(schema['foo'].title, "Woohoo")
def test_readonly_fields(self):
form = self.make_form(fields=['foo', 'bar'])
self.assertEqual(form.readonly_fields, set())
self.assertFalse(form.is_readonly('foo'))
form.set_readonly('foo')
self.assertEqual(form.readonly_fields, {'foo'})
self.assertTrue(form.is_readonly('foo'))
self.assertFalse(form.is_readonly('bar'))
form.set_readonly('bar')
self.assertEqual(form.readonly_fields, {'foo', 'bar'})
self.assertTrue(form.is_readonly('foo'))
self.assertTrue(form.is_readonly('bar'))
form.set_readonly('foo', False)
self.assertEqual(form.readonly_fields, {'bar'})
self.assertFalse(form.is_readonly('foo'))
self.assertTrue(form.is_readonly('bar'))
def test_render_vue_tag(self): def test_render_vue_tag(self):
schema = self.make_schema() schema = self.make_schema()
form = self.make_form(schema=schema) form = self.make_form(schema=schema)
@ -183,13 +231,13 @@ class TestForm(TestCase):
# form button is disabled on @submit by default # form button is disabled on @submit by default
schema = self.make_schema() schema = self.make_schema()
form = self.make_form(schema=schema) form = self.make_form(schema=schema, cancel_url='/')
html = form.render_vue_template() html = form.render_vue_template()
self.assertIn('<script type="text/x-template" id="wutta-form-template">', html) self.assertIn('<script type="text/x-template" id="wutta-form-template">', html)
self.assertIn('@submit', html) self.assertIn('@submit', html)
# but not if form is configured otherwise # but not if form is configured otherwise
form = self.make_form(schema=schema, auto_disable_submit=False) form = self.make_form(schema=schema, auto_disable_submit=False, cancel_url='/')
html = form.render_vue_template() html = form.render_vue_template()
self.assertIn('<script type="text/x-template" id="wutta-form-template">', html) self.assertIn('<script type="text/x-template" id="wutta-form-template">', html)
self.assertNotIn('@submit', html) self.assertNotIn('@submit', html)
@ -224,6 +272,25 @@ class TestForm(TestCase):
html = form.render_vue_field('foo') html = form.render_vue_field('foo')
self.assertIn(':message="`something is wrong`"', html) self.assertIn(':message="`something is wrong`"', html)
# add another field, but not to deform, so it should still
# display but with no widget
form.fields.append('zanzibar')
html = form.render_vue_field('zanzibar')
self.assertIn('<b-field :horizontal="true" label="Zanzibar">', html)
self.assertNotIn('<b-input', html)
# nb. no error message
self.assertNotIn('message', html)
# try that once more but with a model record instance
with patch.object(form, 'model_instance', new={'zanzibar': 'omgwtfbbq'}):
html = form.render_vue_field('zanzibar')
self.assertIn('<b-field', html)
self.assertIn('label="Zanzibar"', html)
self.assertNotIn('<b-input', html)
self.assertIn('>omgwtfbbq<', html)
# nb. no error message
self.assertNotIn('message', html)
def test_get_field_errors(self): def test_get_field_errors(self):
schema = self.make_schema() schema = self.make_schema()
form = self.make_form(schema=schema) form = self.make_form(schema=schema)

View file

@ -18,11 +18,11 @@ from tests.views.utils import WebTestCase
class TestMasterView(WebTestCase): class TestMasterView(WebTestCase):
def test_defaults(self): def test_defaults(self):
master.MasterView.model_name = 'Widget' with patch.multiple(master.MasterView, create=True,
with patch.object(master.MasterView, 'viewable', new=False): model_name='Widget',
# TODO: should inspect pyramid routes after this, to be certain viewable=False,
editable=False):
master.MasterView.defaults(self.pyramid_config) master.MasterView.defaults(self.pyramid_config)
del master.MasterView.model_name
############################## ##############################
# class methods # class methods
@ -374,17 +374,75 @@ class TestMasterView(WebTestCase):
def test_view(self): def test_view(self):
# sanity/coverage check using /settings/XXX # sanity/coverage check using /settings/XXX
master.MasterView.model_name = 'Setting'
master.MasterView.grid_columns = ['name', 'value']
master.MasterView.form_fields = ['name', 'value']
view = master.MasterView(self.request)
setting = {'name': 'foo.bar', 'value': 'baz'} setting = {'name': 'foo.bar', 'value': 'baz'}
self.request.matchdict = {'name': 'foo.bar'} self.request.matchdict = {'name': 'foo.bar'}
with patch.object(view, 'get_instance', return_value=setting): with patch.multiple(master.MasterView, create=True,
response = view.view() model_name='Setting',
del master.MasterView.model_name model_key='name',
del master.MasterView.grid_columns grid_columns=['name', 'value'],
del master.MasterView.form_fields form_fields=['name', 'value']):
view = master.MasterView(self.request)
with patch.object(view, 'get_instance', return_value=setting):
response = view.view()
def test_edit(self):
model = self.app.model
self.app.save_setting(self.session, 'foo.bar', 'frazzle')
self.session.commit()
def get_instance():
setting = self.session.query(model.Setting).get('foo.bar')
return {
'name': setting.name,
'value': setting.value,
}
# sanity/coverage check using /settings/XXX/edit
self.request.matchdict = {'name': 'foo.bar'}
with patch.multiple(master.MasterView, create=True,
model_name='Setting',
model_key='name',
form_fields=['name', 'value']):
view = master.MasterView(self.request)
with patch.object(view, 'get_instance', new=get_instance):
# get the form page
response = view.edit()
self.assertIsInstance(response, Response)
self.assertEqual(response.status_code, 200)
self.assertIn('frazzle', response.text)
# nb. no error
self.assertNotIn('Required', response.text)
def persist(setting):
self.app.save_setting(self.session, setting['name'], setting['value'])
self.session.commit()
# post request to save settings
self.request.method = 'POST'
self.request.POST = {
'name': 'foo.bar',
'value': 'froogle',
}
with patch.object(view, 'persist', new=persist):
response = view.edit()
# nb. should get redirect back to view page
self.assertIsInstance(response, HTTPFound)
# setting should be updated in DB
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'froogle')
# try another post with invalid data (name is required)
self.request.method = 'POST'
self.request.POST = {
'value': 'gargoyle',
}
with patch.object(view, 'persist', new=persist):
response = view.edit()
# nb. should get a form with errors
self.assertEqual(response.status_code, 200)
self.assertIn('Required', response.text)
# setting did not change in DB
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'froogle')
def test_configure(self): def test_configure(self):
model = self.app.model model = self.app.model

View file

@ -65,3 +65,30 @@ class TestSettingView(WebTestCase):
view = self.make_view() view = self.make_view()
title = view.get_instance_title(setting) title = view.get_instance_title(setting)
self.assertEqual(title, 'foo') self.assertEqual(title, 'foo')
def test_make_model_form(self):
view = self.make_view()
view.editing = True
form = view.make_model_form()
self.assertEqual(form.fields, ['name', 'value'])
self.assertIn('name', form)
self.assertIn('value', form)
dform = form.get_deform()
self.assertNotIn('name', dform)
self.assertIn('value', dform)
def test_persist(self):
model = self.app.model
view = self.make_view()
# setup
self.app.save_setting(self.session, 'foo', 'bar')
self.session.commit()
self.assertEqual(self.session.query(model.Setting).count(), 1)
# setting is updated
self.request.matchdict = {'name': 'foo'}
view.persist({'name': 'foo', 'value': 'frazzle'}, session=self.session)
self.session.commit()
self.assertEqual(self.session.query(model.Setting).count(), 1)
self.assertEqual(self.app.get_setting(self.session, 'foo'), 'frazzle')