Reusing forms for read-only views

11 views
Skip to first unread message

Paul Stadler

unread,
Feb 21, 2016, 5:04:15 AM2/21/16
to Reahl discuss
Hi,

I'm in the beginning of build an app using reahl and a couple questions came up, was hoping to get your input. BTW... really great work on this. 

I've tried to re-use a form for a display view. I did this because the ultimate form will be complex (probably 50+ fields). Creating a separate read-only view from the form itself doubles the maintenance cost and code volume on this. Here's the initial approach I took:

class EditClientForm(Form):
    def __init__(self,view,client,edit=False):
        super(EditClientForm,self).__init__(view,'client_form')
        label_text = 'Client Information'
        if edit==True: label_text = 'Edit Client Information'
        grouped_inputs = self.add_child(InputGroup(view,label_text=label_text))
        print(client.fields.firstname)
        fields = [
            client.fields.firstname,
            client.fields.lastname,
            client.fields.email_address
        ]
        for field in fields:
            textinput = TextInput(self,field)
            if edit==False: textinput.set_attribute('disabled','True')
            grouped_inputs.add_child(LabelledBlockInput(textinput))
        if edit==True:
            grouped_inputs.add_child(Button(self,client.events.save.with_arguments(client_id=client.id)))
            grouped_inputs.add_child(Button(self,client.events.cancel.with_arguments(client_id=client.id)))
        else:
            grouped_inputs.add_child(Button(self,client.events.edit.with_arguments(client_id=client.id)))

The problem with this is that reahl validates the fields on post. So when I disable a field it breaks the post mechanism (or so I think based on the error messages).

ProgrammerError: Could not find a value for expected input <TextInput name=firstname> (named firstname) on form <EditClientForm form id=client_form>

Do you have any ideas on alternate ways to do this?

I'll try to keep questions in separate thread. Your input and help is much appreciated!

Paul

Paul Stadler

unread,
Feb 21, 2016, 6:13:43 AM2/21/16
to Reahl discuss
As an update to this, I've added this: textinput.set_attribute('readonly','True')
... but then we get into how to modify the CSS explicitly for a single call so it can be visually indicated as readonly. 
Any hints are most appreciated. :)
-Paul

Iwan Vosloo

unread,
Feb 21, 2016, 9:52:48 AM2/21/16
to reahl-...@googlegroups.com
Hi Paul,

Thanks. Its really great if new people try out our stuff.

Just one question before I answer: How do you decide when this has to be
an edit form, and when it should be view-only? Is that based on the user
that is currently logged in, or do you re-use the form on different URLs?

Regards
- Iwan


--
Reahl, the Python only web framework: http://www.reahl.org

Paul Stadler

unread,
Feb 21, 2016, 9:59:11 AM2/21/16
to Reahl discuss
Thanks Iwan. I was doing this w/ two separate views that are URL bound.

class ClientView(UrlBoundView):
    def assemble(self,client_id=None):
        try:
            client = Session.query(Client).filter_by(id=client_id).one()
        except NoResultFound:
            raise CannotCreate()
        self.title = 'Client View - %s' % client.lastname
        self.set_slot('main',EditClientForm.factory(client,False))

class EditClientView(UrlBoundView):
    def assemble(self,client_id=None):
        try:
            client = Session.query(Client).filter_by(id=client_id).one()
        except NoResultFound:
            raise CannotCreate()
        self.title = 'Editing %s' % client.lastname
        self.set_slot('main',EditClientForm.factory(client,True))

Iwan Vosloo

unread,
Feb 21, 2016, 11:07:03 AM2/21/16
to reahl-...@googlegroups.com
Ok.

Usually, whether someone can edit/only see some piece of information about a domain object depends on things like:
 - The user has certain "rights" with respect to the object being viewed/edited; or
 - The object being edited is in a certain state.

For example, perhaps I can edit the details of an order I create, but only until it has been submitted - thereafter I can also only view. Someone else should not be able to edit (or view!) my order regardless of its state.

It is for this type of scenario that Fields also have access rights attached to them. So you can do stuff like:

class Order(Base):
     @exposed
     def fields(self, fields):
           fields.number_of_items = IntegerField(label='Number of Items', writable=Action(self.can_be_edited))

     def can_be_edited(self):
           current_account = LoginSession.for_current_session().account
           return (current_account is self.owner) and (not self.submitted)

(for example)

If you think about the world like this, it starts to feel funny to have one view for editing and another for viewing. Why can't you only have one view and let each Field determine whether it can be changed depending on your domain logic?

Stated in another way: if I view something, why would I want to go somewhere else to edit it?

This is why we work on the assumption that you only have one view, and that the Fields determine from your domain when they'd be editable or not.

(This 'access control' stuff is explained in this larger example: http://www.reahl.org/docs/3.1/tutorial/accesscontrol.d.html )

That said, let me answer your question without the above design bias:

Fields determine whether the Inputs they are linked to are visible, read-only or editable etc. They do some work on the server side to enforce these rules also, so that someone can't just, say, remove a readonly attribute in the HTML presented, and so manages to submit the value despite your intentions to forbid this. Hence, to do what you want to do you really need to use Fields. They do more work than what you are aware of.

Changing the access rights on a Field is something we did not make simple, because the intention has always been to specify access rights when you create the Field. But you can do this. In your example, you can do something like:

    firstname = client.fields.firstname.copy()
    firstname.access_rights.writable = Allowed(False)

... and then use the changed `firstname` field when creating Inputs.

(`firstname` will stay bound to the client object where it was copied from)

Paul Stadler

unread,
Feb 21, 2016, 11:29:19 AM2/21/16
to Reahl discuss
Thanks Iwan.

That makes a lot of sense.

I think where I'm stuck is that I was trying to use a transition (attached to a button) to go from a view mode to an Edit mode. I think that necessitates separate views since the views themselves are tied into the define_transition logic. Here's how I'd attempted to do that:

class ClientsUserInterface(UserInterface):
    def assemble(self):
        client_list = self.define_view('/',title='Clients')
        client_list.set_slot('main',ClientListPanel.factory(self))
        client_view = self.define_view('/view', view_class=ClientView,
            title='View', client_id=IntegerField(required=True))
        client_edit = self.define_view('/edit', view_class=EditClientView,
            title='Edit', client_id=IntegerField(required=True))
        self.define_transition(Client.events.save,client_edit,client_view)
        self.define_transition(Client.events.cancel,client_edit,client_view)
        self.define_transition(Client.events.edit,client_view,client_edit)
        self.client_edit = client_edit
        self.client_view = client_view

    def get_edit_bookmark(self, client, description=None):
        return self.client_edit.as_bookmark(self, 
            client_id=client.id, description='%s, %s'%(client.lastname,client.firstname))

    def get_view_bookmark(self, client, description=None):
        return self.client_view.as_bookmark(self, 
            client_id=client.id, description='%s, %s'%(client.lastname,client.firstname))

Is there an alternative mechanism for this?

My end goal here is to create a form that's capable of editing, but doesn't permit it until someone clicks the "Edit" button -- call it a safety feature. I'd then tie security into the fields and/or into the button itself to add access control later.

Cheers,
Paul

p.s. I'm posting the whole file here. This is invoked as a child user interface.

#-------------------------------------------------------------------------------
from __future__ import print_function, unicode_literals, absolute_import, division

from sqlalchemy import Column, Integer, UnicodeText
from reahl.sqlalchemysupport import Session, Base

from reahl.web.fw import UserInterface, Widget, Url, UrlBoundView
from reahl.web.ui import HTML5Page, Panel, P, H, A, HorizontalLayout, VerticalLayout
from reahl.web.ui import Form, InputGroup, LabelledBlockInput, TextInput, Button, Table
from reahl.web.ui import TabbedPanel, Tab
from reahl.web.ui import StaticColumn, DynamicColumn
from reahl.web.table import DataTable
from reahl.component.modelinterface import exposed, EmailField, Field, Event, Action, IntegerField

#-------------------------------------------------------------------------------
# UI Definition

class ClientsUserInterface(UserInterface):
    def assemble(self):
        client_list = self.define_view('/',title='Clients')
        client_list.set_slot('main',ClientListPanel.factory(self))
        client_view = self.define_view('/view', view_class=ClientView,
            title='View', client_id=IntegerField(required=True))
        client_edit = self.define_view('/edit', view_class=EditClientView,
            title='Edit', client_id=IntegerField(required=True))
        self.define_transition(Client.events.save,client_edit,client_view)
        self.define_transition(Client.events.cancel,client_edit,client_view)
        self.define_transition(Client.events.edit,client_view,client_edit)
        self.client_edit = client_edit
        self.client_view = client_view

    def get_edit_bookmark(self, client, description=None):
        return self.client_edit.as_bookmark(self, 
            client_id=client.id, description='%s, %s'%(client.lastname,client.firstname))

    def get_view_bookmark(self, client, description=None):
        return self.client_view.as_bookmark(self, 
            client_id=client.id, description='%s, %s'%(client.lastname,client.firstname))

#-------------------------------------------------------------------------------
# List View

class ClientRow(object):
    def __init__(self,client):
        self.client = client
        self.selected_by_user = False
    @exposed
    def fields(self, fields):
        fields.selected_by_user = BooleanField(label='')

    def __getattr__(self, name):
        return getattr(self.client, name)

class ClientListPanel(Panel):
    def __init__(self,view,clients_ui):
        super(ClientListPanel,self).__init__(view)
        self.rows = self.initialize_rows()
        self.add_child(H(view,1,text='Clients'))
        def make_link_widget(view,row):
            return A.from_bookmark(view,clients_ui.get_view_bookmark(row.client))
        columns = [
            DynamicColumn('Name', make_link_widget, sort_key=lambda x: x.lastname),
            StaticColumn(EmailField(label='Email'),'email_address',sort_key=lambda x: x.client.email_address)
        ]
        data_table = DataTable(view, columns, self.rows, caption_text='All Clients',
            summary='Summary for screen reader',css_id='address_data')
        self.add_child(data_table)

    def initialize_rows(self):
        return [ClientRow(client) for client in Session.query(Client).all()]

#-------------------------------------------------------------------------------
# Client View

class ClientView(UrlBoundView):
    def assemble(self,client_id=None):
        try:
            client = Session.query(Client).filter_by(id=client_id).one()
        except NoResultFound:
            raise CannotCreate()
        self.title = 'Client View - %s' % client.lastname
#        self.set_slot('main',ViewClientForm.factory(client))
        self.set_slot('main',EditClientForm.factory(client,False))

class EditClientView(UrlBoundView):
    def assemble(self,client_id=None):
        try:
            client = Session.query(Client).filter_by(id=client_id).one()
        except NoResultFound:
            raise CannotCreate()
        self.title = 'Editing %s' % client.lastname
        self.set_slot('main',EditClientForm.factory(client,True))

class ViewClientForm(Form):
    def __init__(self,view,client):
        super(ViewClientForm,self).__init__(view,'view')
        self.add_child(P(view,text='First Name: %s'%client.firstname))
        self.add_child(P(view,text='Last Name: %s'%client.lastname))
        self.add_child(P(view,text='Email Address: %s'%client.email_address))
        self.add_child(Button(self,client.events.edit.with_arguments(client_id=client.id)))

class EditClientForm(Form):
    def __init__(self,view,client,edit=False):
        super(EditClientForm,self).__init__(view,'client_form')
        label_text = 'Client Information'
        if edit==True: label_text = 'Edit Client Information'
        grouped_inputs = self.add_child(InputGroup(view,label_text=label_text))
        fields = [
            client.fields.firstname,
            client.fields.lastname,
            client.fields.email_address
        ]
        for field in fields:
            textinput = TextInput(self,field)
            if edit==False: textinput.set_attribute('readonly','True')
            grouped_inputs.add_child(LabelledBlockInput(textinput))
        tabbed_panel = self.add_child(TabbedPanel(view,'tabbed_panel'))
        tab1_contents = P.factory(text="blah blah")
        tab2_contents = P.factory(text="blah blah")
        tab3_contents = P.factory(text="blah blah")
        tab4_contents = P.factory(text="blah blah")
        tabbed_panel.add_tab(Tab(view,'Patients','1',tab1_contents))
        tabbed_panel.add_tab(Tab(view,'Contacts','2',tab2_contents))
        tabbed_panel.add_tab(Tab(view,'Discounts','3',tab3_contents))
        tabbed_panel.add_tab(Tab(view,'Notes','4',tab4_contents))
            
        if edit==True:
            grouped_inputs.add_child(Button(self,client.events.save.with_arguments(client_id=client.id)))
            grouped_inputs.add_child(Button(self,client.events.cancel.with_arguments(client_id=client.id)))
        else:
            grouped_inputs.add_child(Button(self,client.events.edit.with_arguments(client_id=client.id)))
            
#-------------------------------------------------------------------------------
# Persistance
from sqlalchemy import Column, Integer, UnicodeText
from reahl.sqlalchemysupport import Session, Base

class Client(Base):
    __tablename__ = 'clients'

    id = Column(Integer, primary_key=True)
    email_address = Column(UnicodeText)
    firstname = Column(UnicodeText)
    lastname = Column(UnicodeText)
#    street = Column(UnicodeText)
#    city = Column(UnicodeText)
#    state = Column(UnicodeText)
#    postal = Column(UnicodeText)
#    phone = Column(UnicodeText)

    @exposed
    def fields(self, fields):
        fields.firstname = Field(label='First Name', required=True)
        fields.lastname = Field(label='Last Name', required=True)
        fields.email_address = EmailField(label='Email', required=True)

    def save(self):
        Session.add(self)

    @exposed('save','edit','cancel')
    def events(self, events):
        events.save = Event(label='Save', action=Action(self.save))
        events.edit = Event(label='Edit')
        events.cancel = Event(label='Cancel')





Iwan Vosloo

unread,
Feb 21, 2016, 12:56:27 PM2/21/16
to reahl-...@googlegroups.com
Hi Paul,

Ok, if you insist on that kind of user interface I can try to come up with a way of doing it that may be cleaner than what you have, but I'll have to think about it a bit. I'll try to send a better reply when I have a bit more time. In the mean time, here is the quick-and-dirty (minimal edits to your code) way to get what you currently have working:


class EditClientForm(Form):
    def __init__(self,view,client,edit=False):
        super(EditClientForm,self).__init__(view,'client_form')
        label_text = 'Client Information'
        if edit==True: label_text = 'Edit Client Information'
        grouped_inputs = self.add_child(InputGroup(view,label_text=label_text))
        fields = [
            client.fields.firstname,
            client.fields.lastname,
            client.fields.email_address
        ]
        for field in fields:
                   copied = field.copy()
                   copied.access_rights.writable = Allowed(edit)
                   textinput = TextInput(self, copied)

            grouped_inputs.add_child(LabelledBlockInput(textinput))
        tabbed_panel = self.add_child(TabbedPanel(view,'tabbed_panel'))
        tab1_contents = P.factory(text="blah blah")
        tab2_contents = P.factory(text="blah blah")
        tab3_contents = P.factory(text="blah blah")
        tab4_contents = P.factory(text="blah blah")
        tabbed_panel.add_tab(Tab(view,'Patients','1',tab1_contents))
        tabbed_panel.add_tab(Tab(view,'Contacts','2',tab2_contents))
        tabbed_panel.add_tab(Tab(view,'Discounts','3',tab3_contents))
        tabbed_panel.add_tab(Tab(view,'Notes','4',tab4_contents))
            
        if edit==True:
            grouped_inputs.add_child(Button(self,client.events.save.with_arguments(client_id=client.id)))
            grouped_inputs.add_child(Button(self,client.events.cancel.with_arguments(client_id=client.id)))
        else:
            grouped_inputs.add_child(Button(self,client.events.edit.with_arguments(client_id=client.id)))
            


Paul Stadler

unread,
Feb 21, 2016, 1:07:11 PM2/21/16
to Reahl discuss
Thanks so much Iwan.

Actually I don't insist on an interface like that... However, the application I have in mind is targeted towards unskilled users. So I envision need for lots of explicit actions around editing, even when write access is granted to the user (all users would have write access to this particular model... not so w/ financial transactions etc..). If there's a better design pattern for granting write access based on an explicit action I'd love to learn -- I'm not a UI expert by any stretch of the imagination. The application in place today (which I'm toying around w/ building in-house) has this functionality, so I'm using it as a reference.

I really appreciate your taking the time to work through it w/ me.

Cheers,
Paul

Iwan Vosloo

unread,
Feb 22, 2016, 12:27:18 AM2/22/16
to reahl-...@googlegroups.com
Hi Paul,

Here is another way you can do it.

If you only have a single view, with a singe form on it, you can rig the form so that it can be flipped between a read-only and read-write state. This makes use of widget arguments (derived from the query string) as explained here: http://www.reahl.org/docs/3.1/tutorial/whathappenedtoajax.d.html

This solution your edit/cancel will be links, not buttons. If you want to, you can always style them to look like buttons. But here they have the semantics of links (taking you to a bookmark).

You can also consider moving the save event from the Client to the form itself, since you actually don't need to do anything to "save" the client in order to update its details. (And "save" in this context is not an operation a client can do.) But that's another story...

This is what your form would look like:


 class EditClientForm(Form):
     def __init__(self, view, client):  # Note, edit not passed in anymore
         super(EditClientForm,self).__init__(view,'client_form')
         self.enable_refresh()

         label_text = 'Client Information'
         if self.edit: label_text = 'Edit Client Information'

         grouped_inputs = self.add_child(InputGroup(view,label_text=label_text))
         fields = [
             client.fields.firstname,
             client.fields.lastname,
             client.fields.email_address
         ]
         for field in fields:
             textinput = TextInput(self, self.protected(field))


         grouped_inputs.add_child(LabelledBlockInput(textinput))
         tabbed_panel = self.add_child(TabbedPanel(view,'tabbed_panel'))
         tab1_contents = P.factory(text="blah blah")
         tab2_contents = P.factory(text="blah blah")
         tab3_contents = P.factory(text="blah blah")
         tab4_contents = P.factory(text="blah blah")
         tabbed_panel.add_tab(Tab(view,'Patients','1',tab1_contents))
         tabbed_panel.add_tab(Tab(view,'Contacts','2',tab2_contents))
         tabbed_panel.add_tab(Tab(view,'Discounts','3',tab3_contents))
         tabbed_panel.add_tab(Tab(view,'Notes','4',tab4_contents))

         if self.edit:
            self.add_child(A.from_bookmark(view, Bookmark.for_widget('cancel', query_arguments=dict(edit='off'))))
         else:
            self.add_child(A.from_bookmark(view, Bookmark.for_widget('edit', query_arguments=dict(edit='on'))))
            
         grouped_inputs.add_child(Button(self,self.protected(client.events.save.with_arguments(client_id=client.id))))

            
     def protected(self, field):
         if not self.edit:
             readonly_field = field.copy()
             readonly_field.access_rights.writable = Allowed(self.edit)
             return readonly_field
         else:
             return field

     @exposed
     def query_fields(self, fields):
         fields.edit = BooleanField(required=False, default=False)


Regards
- Iwan

Reply all
Reply to author
Forward
0 new messages