One Page CRUD

This example handles all the CRUD functions with a single page.
Notice how, despite being a single page, it produces a readable URL that can be bookmarked.
Create...

Review

Id
1
Version
14
Name
Edgar Cayce
Region
West Coast
Start Date
09/01/2009
Home

The source for IPersonFinderServiceLocal, IPersonManagerServiceLocal, and @EJB is shown in the Session Beans and @EJB examples.


<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<!-- We need a doctype to allow us to use special characters like &nbsp; 
     We use a "strict" DTD to make IE follow the alignment rules. -->
     
<html xmlns:t="http://tapestry.apache.org/schema/tapestry_5_4.xsd" xmlns:p="tapestry:parameter">
<body class="container">
    <h1>One Page CRUD</h1>
    
    This example handles all the CRUD functions with a single page.<br/>
    Notice how, despite being a single page, it produces a readable URL that can be bookmarked.<br/>

    <div class="eg">
        <t:eventLink event="toCreate">Create...</t:eventLink><br/>
        
        <table id="listAndEditor">
            <tbody>
                <tr>
    
                    <!-- This is the left side of the table: a list of Persons -->
    
                    <td id="listSide">
                        <div id="listContainer">
    
                            <div id="listTitle">
                                Person
                            </div>
        
                            <div id="personList">
                                <t:grid t:id="list" source="listPersons" row="listPerson" class="table"
                                    exclude="id,version,firstName,lastName,region,startDate" add="name"
                                    rowsPerPage="4" pagerPosition="bottom"
                                    empty="block:emptyPersons">
                                    <p:nameCell>
                                        <a t:type="eventLink" t:event="personSelected" t:context="listPerson.id" class="prop:linkCSSClass" href="#">
                                            ${listPerson.firstName} ${listPerson.lastName}
                                        </a>
                                    </p:nameCell>
                                </t:grid>
                            </div>
                            
                            <t:block t:id="emptyPersons">
                                <div id="noPersons">
                                    (No persons found)
                                </div>
                            </t:block>

                        </div>
                    </td>
                    
                    <!-- This is the right side of the table: where a Person will be created, reviewed, or updated. -->
            
                    <td id="editorSide">
    
                        <t:if test="modeCreate">
                        
                            <h1>Create</h1>
                            
                            <t:form t:id="createForm" class="form-horizontal" validate="editorPerson">
                                <t:errors globalOnly="true"/>
                                <t:delegate to="block:editor"/>
        
                                <div class="form-group">
                                    <div class="col-sm-4 col-sm-offset-4">
                                        <t:submit value="Save"/>
                                        <t:eventlink event="cancelCreate" class="btn btn-default">Cancel</t:eventlink>
                                    </div>
                                </div>
                            </t:form>
                        </t:if>
    
                        <t:if test="modeReview">
                        
                            <h1>Review</h1>
                            
                            <t:if test="editorPerson">
                                <div t:type="if" t:test="deleteMessage" class="error">
                                    ${deleteMessage}
                                </div>
                                
                                <t:beandisplay object="editorPerson" include="id,version" add="name,regionX,startDateX">
                                    <p:name>
                                        ${editorPerson.firstName} ${editorPerson.lastName}
                                    </p:name>
                                    <p:regionX>
                                        ${editorPersonRegion}
                                    </p:regionX>
                                    <p:startDateX>
                                        <t:output value="editorPerson.startDate" format="prop:dateFormat"/>
                                    </p:startDateX>
                                </t:beandisplay>
    
                                <div class="buttons">
                                    <t:eventlink event="toUpdate" context="editorPerson.id">Update...</t:eventlink>
                                    <t:eventlink event="delete" context="[editorPerson.id,editorPerson.version]" 
                                        t:mixins="Confirm" Confirm.message="Delete ${editorPerson.firstName} ${editorPerson.lastName}?">Delete...</t:eventlink>
                                </div>
                            </t:if>
    
                            <t:if test="!editorPerson">
                                Person ${editorPersonId} does not exist.<br/><br/>
                            </t:if>
                            
                        </t:if>
    
                        <t:if test="modeUpdate">
                        
                            <h1>Update</h1>
                            
                            <t:form t:id="updateForm" class="form-horizontal" validate="editorPerson">
                                <t:errors globalOnly="true"/>
                            
                                <t:if test="editorPerson">
                                    <!-- If optimistic locking is not needed then comment out this next line. It works because Hidden fields are part of the submit. -->
                                    <t:hidden value="editorPerson.version"/>
                                    <t:delegate to="block:editor"/>

                                    <div class="form-group">
                                        <div class="col-sm-4 col-sm-offset-4">
                                            <t:submit value="Save"/>
                                            <t:eventlink event="cancelUpdate" context="editorPersonId" class="btn btn-default">Cancel</t:eventlink>
                                        </div>
                                    </div>
                                </t:if>

                                <t:if test="!editorPerson">
                                    Person ${editorPersonId} does not exist.<br/><br/>
                                </t:if>
                            </t:form>
                            
                        </t:if>
     
                    </td>
                    
                </tr>
            </tbody>
        </table>
        
        <t:block t:id="editor">
            <div class="form-group">
                <t:label for="firstName" class="col-sm-4"/>
                <div class="col-sm-4">
                    <t:textfield t:id="firstName" value="editorPerson.firstName"/>
                </div>
                <div class="col-sm-4">
                    <p class="form-control-static">
                        (required)
                    </p>
                </div>
            </div>
            <div class="form-group">
                <t:label for="lastName" class="col-sm-4"/>
                <div class="col-sm-4">
                    <t:textfield t:id="lastName" value="editorPerson.lastName"/>
                </div>
                <div class="col-sm-4">
                    <p class="form-control-static">
                        (required)
                    </p>
                </div>
            </div>
            <div class="form-group">
                <t:label for="region" class="col-sm-4"/>
                <div class="col-sm-4">
                    <t:select t:id="region" value="editorPerson.region" blankOption="always"/>
                </div>
                <div class="col-sm-4">
                    <p class="form-control-static">
                        (required)
                    </p>
                </div>
            </div>
            <div class="form-group">
                <t:label for="startDate" class="col-sm-4"/>
                <div class="col-sm-4">
                    <t:datefield t:id="startDate" value="editorPerson.startDate" format="prop:dateFormat"/>
                </div>
                <div class="col-sm-4">
                    <p class="form-control-static">
                        (required, ${datePattern})
                    </p>
                </div>
            </div>
        </t:block>
        
    </div>

    <t:pagelink page="Index">Home</t:pagelink><br/><br/>
    
    The source for IPersonFinderServiceLocal, IPersonManagerServiceLocal, and @EJB is shown in the Session Beans and @EJB examples.<br/><br/>

    <t:tabgroup>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/together/onepagecrud/Persons.tml"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/together/onepagecrud/Persons.properties"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/together/onepagecrud/Persons.java"/>
        <t:sourcecodetab src="/web/src/main/resources/META-INF/assets/css/together/onepagecrud.css"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/models/together/PersonPagedDataSource.java"/>
        <t:sourcecodetab src="/business/src/main/java/jumpstart/util/query/SortCriterion.java"/>
        <t:sourcecodetab src="/business/src/main/java/jumpstart/util/query/SortDirection.java"/>
        <t:sourcecodetab src="/business/src/main/java/jumpstart/business/domain/person/Person.java"/>
        <t:sourcecodetab src="/business/src/main/java/jumpstart/business/domain/person/Regions.java"/>
    </t:tabgroup>
</body>
</html>


regionX-label=Region
startDateX-label=Start Date

region-blankLabel=Choose...

## These enum conversions could be moved to the central message properties file called app.properties
## The structure we've chosen (enum class name, dot, enum value) is the same as expected by the Select component.
Regions.EAST_COAST=East Coast
Regions.WEST_COAST=West Coast


package jumpstart.web.pages.together.onepagecrud;

import java.text.Format;
import java.text.SimpleDateFormat;

import javax.ejb.EJB;

import jumpstart.business.domain.person.Person;
import jumpstart.business.domain.person.Regions;
import jumpstart.business.domain.person.iface.IPersonFinderServiceLocal;
import jumpstart.business.domain.person.iface.IPersonManagerServiceLocal;
import jumpstart.util.ExceptionUtil;
import jumpstart.web.models.together.PersonPagedDataSource;

import org.apache.tapestry5.EventContext;
import org.apache.tapestry5.PersistenceConstants;
import org.apache.tapestry5.annotations.Import;
import org.apache.tapestry5.annotations.InjectComponent;
import org.apache.tapestry5.annotations.Persist;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.corelib.components.Form;
import org.apache.tapestry5.corelib.components.TextField;
import org.apache.tapestry5.grid.GridDataSource;
import org.apache.tapestry5.ioc.Messages;
import org.apache.tapestry5.ioc.annotations.Inject;

@Import(stylesheet = "css/together/onepagecrud.css")
public class Persons {

    private final String demoModeStr = System.getProperty("jumpstart.demo-mode");

    private enum Mode {
        CREATE, REVIEW, UPDATE;
    }

    // The activation context

    @Property
    private Mode editorMode;

    @Property
    private Long editorPersonId;

    // Screen fields

    @Property
    private GridDataSource listPersons;

    @Property
    private Person listPerson;

    @Property
    private Person editorPerson;

    @Property
    @Persist(PersistenceConstants.FLASH)
    private String deleteMessage;

    // Generally useful bits and pieces

    @EJB
    private IPersonFinderServiceLocal personFinderService;

    @EJB
    private IPersonManagerServiceLocal personManagerService;

    @InjectComponent
    private Form createForm;

    @InjectComponent
    private Form updateForm;

    @InjectComponent("firstName")
    private TextField firstNameField;

    @Inject
    private Messages messages;

    // The code

    void onActivate(EventContext ec) {

        if (ec.getCount() == 0) {
            editorMode = null;
            editorPersonId = null;
        }
        else if (ec.getCount() == 1) {
            editorMode = ec.get(Mode.class, 0);
            editorPersonId = null;
        }
        else {
            editorMode = ec.get(Mode.class, 0);
            editorPersonId = ec.get(Long.class, 1);
        }

    }

    Object[] onPassivate() {

        if (editorMode == null) {
            return null;
        }
        else if (editorMode == Mode.CREATE) {
            return new Object[] { editorMode };
        }
        else if (editorMode == Mode.REVIEW || editorMode == Mode.UPDATE) {
            return new Object[] { editorMode, editorPersonId };
        }
        else {
            throw new IllegalStateException(editorMode.toString());
        }

    }

    void setupRender() {
        listPersons = new PersonPagedDataSource(personFinderService);

        if (editorMode == Mode.REVIEW) {
            if (editorPersonId == null) {
                editorPerson = null;
                // Handle null editorPerson in the template.
            }
            else {
                if (editorPerson == null) {
                    editorPerson = personFinderService.findPerson(editorPersonId);
                    // Handle null editorPerson in the template.
                }
            }
        }

    }

    void onToCreate() {
        editorMode = Mode.CREATE;
        editorPersonId = null;
    }

    void onPersonSelected(Long personId) {
        editorMode = Mode.REVIEW;
        editorPersonId = personId;
    }

    // /////////////////////////////////////////////////////////////////////
    // CREATE
    // /////////////////////////////////////////////////////////////////////

    void onCancelCreate() {
        editorMode = null;
        editorPersonId = null;
    }

    void onPrepareForRenderFromCreateForm() throws Exception {

        // If fresh start, make sure there's a Person object available.

        if (createForm.isValid()) {
            editorMode = Mode.CREATE;
            editorPerson = new Person();
        }
    }

    void onPrepareForSubmitFromCreateForm() throws Exception {
        editorMode = Mode.CREATE;
        // Instantiate a Person for the form data to overlay.
        editorPerson = new Person();
    }

    void onValidateFromCreateForm() {

        if (editorPerson.getFirstName() != null && editorPerson.getFirstName().equals("Acme")) {
            createForm.recordError(firstNameField, firstNameField.getLabel() + " must not be Acme.");
        }

        if (demoModeStr != null && demoModeStr.equals("true")) {
            createForm.recordError("Sorry, but Create is not allowed in Demo mode.");
        }

        if (createForm.getHasErrors()) {
            return;
        }

        try {
            editorPerson = personManagerService.createPerson(editorPerson);
        }
        catch (Exception e) {
            // Display the cause. In a real system we would try harder to get a user-friendly message.
            createForm.recordError(ExceptionUtil.getRootCauseMessage(e));
        }
    }

    void onSuccessFromCreateForm() {
        editorMode = Mode.REVIEW;
        editorPersonId = editorPerson.getId();
    }

    // /////////////////////////////////////////////////////////////////////
    // REVIEW
    // /////////////////////////////////////////////////////////////////////

    void onToUpdate(Long personId) {
        editorMode = Mode.UPDATE;
        editorPersonId = personId;
    }

    void onDelete(Long personId, Integer personVersion) {
        editorMode = Mode.REVIEW;
        editorPersonId = personId;

        if (demoModeStr != null && demoModeStr.equals("true")) {
            deleteMessage = "Sorry, but Delete is not allowed in Demo mode.";
            return;
        }

        try {
            personManagerService.deletePerson(personId, personVersion);

            editorMode = null;
            editorPersonId = null;
        }
        catch (Exception e) {
            // Display the cause. In a real system we would try harder to get a user-friendly message.
            deleteMessage = ExceptionUtil.getRootCauseMessage(e);
        }
    }

    // /////////////////////////////////////////////////////////////////////
    // UPDATE
    // /////////////////////////////////////////////////////////////////////

    void onCancelUpdate(Long personId) {
        editorMode = Mode.REVIEW;
        editorPersonId = personId;
    }

    void onPrepareForRenderFromUpdateForm() {

        // If fresh start, make sure there's a Person object available.

        if (updateForm.isValid()) {
            editorMode = Mode.UPDATE;
            editorPerson = personFinderService.findPerson(editorPersonId);
            // Handle null editorPerson in the template.
        }
    }

    void onPrepareForSubmitFromUpdateForm() {
        editorMode = Mode.UPDATE;

        // Get objects for the form fields to overlay.
        editorPerson = personFinderService.findPerson(editorPersonId);

        if (editorPerson == null) {
            updateForm.recordError("Person has been deleted by another process.");
            // Instantiate an empty person to avoid NPE in the Form.
            editorPerson = new Person();
        }
    }

    void onValidateFromUpdateForm() {

        if (editorPerson.getFirstName() != null && editorPerson.getFirstName().equals("Acme")) {
            updateForm.recordError(firstNameField, firstNameField.getLabel() + " must not be Acme.");
        }

        if (editorPersonId == 2 && !editorPerson.getFirstName().equals("Mary")) {
            updateForm.recordError(firstNameField, firstNameField.getLabel() + " for this person must be Mary.");
        }

        if (updateForm.getHasErrors()) {
            return;
        }

        try {
            personManagerService.changePerson(editorPerson);
        }
        catch (Exception e) {
            // Display the cause. In a real system we would try harder to get a user-friendly message.
            updateForm.recordError(ExceptionUtil.getRootCauseMessage(e));
        }
    }

    void onSuccessFromUpdateForm() {
        editorMode = Mode.REVIEW;
        editorPersonId = editorPerson.getId();
    }

    // /////////////////////////////////////////////////////////////////////
    // GETTERS ETC.
    // /////////////////////////////////////////////////////////////////////

    public String getLinkCSSClass() {
        if (listPerson != null && listPerson.getId().equals(editorPersonId)) {
            return "active";
        }
        else {
            return "";
        }
    }

    public boolean isModeCreate() {
        return editorMode == Mode.CREATE;
    }

    public boolean isModeReview() {
        return editorMode == Mode.REVIEW;
    }

    public boolean isModeUpdate() {
        return editorMode == Mode.UPDATE;
    }

    public String getEditorPersonRegion() {
        return messages.get(Regions.class.getSimpleName() + "." + editorPerson.getRegion().name());
    }

    public String getDatePattern() {
        return "dd/MM/yyyy";
    }

    public Format getDateFormat() {
        return new SimpleDateFormat(getDatePattern());
    }
}


.eg {
                margin: 20px 0;
                padding: 20px;
                color: #888;
                border: 1px solid #ddd;
                border-radius: 4px;
                -webkit-border-radius: 4px;
                -mox-border-radius: 4px;
                font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
                font-size: 13px;
                font-weight: normal;
                color: #333;
                line-height: 17px;
                position: relative;
}

.eg a {
                text-decoration: none;
                color: #3D69B6;
}

.eg a:hover {
                text-decoration: underline;
}

#listAndEditor {
                width: 100%;
                max-width: 800px;
                bottom: 0;
                border: none;
                margin: 10px 0 0;
}

#listSide {
                width: 25%;
                border-right: 3px solid white;
                vertical-align: top;
                padding: 0;
}

#listContainer {
                height: 319px;
                background-color: #eee;
}

#listTitle {
                width: 100%;
                padding: 20px 0;
                text-align: center;
                vertical-align: middle;
                background-color: #3d69b6;
                color: white;
                font-weight: bold;
                border-bottom: 2px solid white;
}

#personList {
                background-color: #eee;
}

#personList table {
                margin: 0;
}

#personList table th {
                display: none;
}

#personList table td {
                padding: 0;
                border: 0;
                border-bottom: 2px solid white;
                background-color: #eee;
}

#personList table a {
                width: 100%;
                line-height: 50px;
                display: block;
                text-align: center;
                text-decoration: none;
                color: black;
}

#personList table a:visited {
                color: inherit;
}

#personList table a:hover {
                background: #ccc;
                color: #fff;
                text-decoration: none;
}

#personList table a.active {
                background: #999;
                color: #fff;
}

#personList .pagination {
            margin: 9px 9px 7px;                
}

#noPersons {
                text-align: center;
                padding-top: 20px;
}

#editorSide {
                width: 75%;
                height: 100%;
                vertical-align: top;
                background-color: #eee;
                padding: 20px;
}

#editorSide h1 {
                font-size: large;
                text-align: center;
                margin: 0;
}

#editorSide > dl.well {
                background-color: transparent;
                border: none;
                box-shadow: none;
                margin-bottom: 0;
                padding: 19px;              
}

#editorSide form {
                padding-top: 19px;              
}

#editorSide .buttons {
                text-align: center;
}

.error {
                color: red;
                text-align: center;
                padding-top: 19px;
}


package jumpstart.web.models.together;

import java.util.ArrayList;
import java.util.List;

import jumpstart.business.domain.person.Person;
import jumpstart.business.domain.person.iface.IPersonFinderServiceRemote;
import jumpstart.util.query.SortCriterion;
import jumpstart.util.query.SortDirection;

import org.apache.tapestry5.grid.GridDataSource;
import org.apache.tapestry5.grid.SortConstraint;

public class PersonPagedDataSource implements GridDataSource {
    private IPersonFinderServiceRemote personFinderService;

    private int startIndex;
    private List<Person> preparedResults;

    public PersonPagedDataSource(IPersonFinderServiceRemote personFinderService) {
        this.personFinderService = personFinderService;
    }

    @Override
    public int getAvailableRows() {
        return (int) personFinderService.countPersons();
    }

    @Override
    public void prepare(final int startIndex, final int endIndex, final List<SortConstraint> sortConstraints) {
        List<SortCriterion> sortCriteria = new ArrayList<SortCriterion>();
        sortCriteria.add(new SortCriterion("firstName", SortDirection.ASCENDING));
        sortCriteria.add(new SortCriterion("lastName", SortDirection.ASCENDING));
        preparedResults = personFinderService.findPersons(startIndex, endIndex - startIndex + 1, sortCriteria);

        this.startIndex = startIndex;
    }

    @Override
    public Object getRowValue(final int index) {
        return preparedResults.get(index - startIndex);
    }

    @Override
    public Class<Person> getRowType() {
        return Person.class;
    }

}


package jumpstart.util.query;

import java.io.Serializable;

@SuppressWarnings("serial")
public class SortCriterion implements Serializable {

    private String propertyName;
    private SortDirection sortDirection;

    public SortCriterion(String propertyName, SortDirection sortDirection) {
        this.propertyName = propertyName;
        this.sortDirection = sortDirection;
    }

    public String getPropertyName() {
        return propertyName;
    }

    public SortDirection getSortDirection() {
        return sortDirection;
    }

}


package jumpstart.util.query;

public enum SortDirection {
    ASCENDING, DESCENDING, UNSORTED;
    
    public String toStringForJpql() {
        if (this == ASCENDING) {
            return "";
        }
        else if (this == DESCENDING) {
            return " desc";
        }
        else {
            return "";
        }
    }
}


package jumpstart.business.domain.person;

import java.io.Serializable;
import java.util.Date;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;
import javax.persistence.Version;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;


/**
 * The Person entity.
 */
@Entity
@SuppressWarnings("serial")
public class Person implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(nullable = false)
    private Long id;

    @Version
    @Column(nullable = false)
    private Integer version;

    @Column(length = 10, nullable = false)
    @NotNull
    @Size(max = 10)
    private String firstName;

    @Column(length = 10, nullable = false)
    @NotNull
    @Size(max = 10)
    private String lastName;
    
    @Enumerated(EnumType.STRING)
    @NotNull
    private Regions region;

    @Temporal(TemporalType.DATE)
    @NotNull
    private Date startDate;

    public String toString() {
        final String DIVIDER = ", ";
        
        StringBuilder buf = new StringBuilder();
        buf.append(this.getClass().getSimpleName() + ": ");
        buf.append("[");
        buf.append("id=" + id + DIVIDER);
        buf.append("version=" + version + DIVIDER);
        buf.append("firstName=" + firstName + DIVIDER);
        buf.append("lastName=" + lastName + DIVIDER);
        buf.append("region=" + region + DIVIDER);
        buf.append("startDate=" + startDate);
        buf.append("]");
        return buf.toString();
    }

    // Default constructor is required by JPA.
    public Person() {
    }

    public Person(String firstName, String lastName, Regions region, Date startDate) {
        super();
        this.firstName = firstName;
        this.lastName = lastName;
        this.region = region;
        this.startDate = startDate;
    }

    // The need for an equals() method is discussed at http://www.hibernate.org/109.html
    
    @Override
    public boolean equals(Object obj) {
        return (obj == this) || (obj instanceof Person) && id != null && id.equals(((Person) obj).getId());
    }

    // The need for a hashCode() method is discussed at http://www.hibernate.org/109.html

    @Override
    public int hashCode() {
        return id == null ? super.hashCode() : id.hashCode();
    }

    @PrePersist
    @PreUpdate
    public void validate() throws ValidationException {

    }

    public Long getId() {
        return id;
    }

    public Integer getVersion() {
        return version;
    }

    public void setVersion(Integer version) {
        this.version = version;
    }

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public Regions getRegion() {
        return region;
    }

    public void setRegion(Regions region) {
        this.region = region;
    }

    public Date getStartDate() {
        return startDate;
    }

    public void setStartDate(Date startDate) {
        this.startDate = startDate;
    }

}


package jumpstart.business.domain.person;

public enum Regions {
    EAST_COAST, WEST_COAST;
}