PetOwner entity

In this set of exercises we’ll just focus on the PetOwner entity.

Ex 3.1: Rename PetOwner’s name property

In the domain we are working on, PetOwner has a firstName and a lastName property, not a single name property.

In this exercise, we’ll rename PetOwner's name property to be lastName, and change the fixture script that sets up data to something more realistic.

Solution

git checkout tags/03-01-renames-PetOwner-name-property
mvn clean install
mvn -pl spring-boot:run

Remember you can use the Prototyping  Fixture Scripts menu to setup some example data.

Tasks

Checkout the solution above and review the git history to see the changes that have already been made. These include:

  • property PetOwner#namePetOwner#lastName renamed

  • JPA mappings updated:

    • the corresponding JPQL named queries

    • the method names of PetOwnerRepository

      This is a Spring Data repository, which uses a naming convention to infer the queries

    • uniqueness constraint for PetOwner

  • the action method names of PetOwners domain service renamed

    This also requires updating the menubars.layout.xml, which references these action names.

  • updating the @ActionLayout of the updateName and delete action methods in PetOwner

    In the UI, the buttons for these actions are located close to the renamed "lastName" property

  • renames @Name meta-annotation to @LastName.

    Meta-annotations are a useful way of eliminating duplication where the same value type appears in multiple locations, for example as both an entity property and in action parameters.

Build and run the application to make sure it still runs fine.

Ex 3.2: Add PetOwner’s firstName property

Now that PetOwner has a lastName property, let’s also add a firstName property. We’ll also update our fixture script (which sets up PetOwners) so that it is more descriptive.

Solution

git checkout tags/03-02-adds-PetOwner-firstName-property
mvn clean install
mvn -pl spring-boot:run

Tasks

  • copy @LastName meta-annotation to create @FirstName:

    FirstName.java
    @Property(maxLength = FirstName.MAX_LEN, optionality = Optionality.OPTIONAL)
    @Parameter(maxLength = FirstName.MAX_LEN, optionality = Optionality.OPTIONAL)
    @ParameterLayout(named = "First Name")
    @Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
    @Retention(RetentionPolicy.RUNTIME)
    public @interface FirstName {
    
        int MAX_LEN = 40;
    }

    Note that this property/parameter is optional. Its parameter name has also been updated.

  • add a new (JPA nullable) property firstName to PetOwner:

    @FirstName
    @Column(length = FirstName.MAX_LEN, nullable = true)
    @Getter @Setter @ToString.Include
    @PropertyLayout(fieldSetId = "name", sequence = "2")
    private String firstName;
  • add a new factory method to accept a firstName, and refactor the existing one:

    PetOwner.java
    public static PetOwner withName(String name) {
        return withName(name, null);
    }
    
    public static PetOwner withName(String lastName, String firstName) {
        val simpleObject = new PetOwner();
        simpleObject.setLastName(lastName);
        simpleObject.setFirstName(firstName);
        return simpleObject;
    }
  • remove @Title annotation from lastName property, and add a title() method to derive from both properties:

    PetOwner.java
    public String title() {
        return getLastName() + (getFirstName() != null ? ", " + getFirstName() : "");
    }
  • Update the PetOwner_persona enum with more realistically last names (family names).

    Learn more about fixture scripts here.

Ex 3.3: Modify PetOwner’s updateName action

Although we’ve added a firstName property, currently it can’t be edited. In this exercise we’ll modify the updateName action to also allow the firstName to be changed.

Solution

git checkout tags/03-03-modifies-PetOwner-updateName-action
mvn clean install
mvn -pl spring-boot:run

Tasks

  • update PetOwner#updateName to also accept a new firstName parameter:

    refactor updateName
    PetOwner.java
    @Action(semantics = IDEMPOTENT, commandPublishing = Publishing.ENABLED, executionPublishing = Publishing.ENABLED)
    @ActionLayout(associateWith = "lastName", promptStyle = PromptStyle.INLINE)
    public PetOwner updateName(
            @LastName final String lastName,
            @FirstName String firstName) {
        setLastName(lastName);
        setFirstName(firstName);
        return this;
    }
    public String default0UpdateName() {
        return getLastName();
    }
    public String default1UpdateName() {
        return getFirstName();
    }
  • add in a "default" supporting method for the new parameter.

    PetOwner.java
    public String default1UpdateName() {
        return getFirstName();
    }

    The "default" supporting methods are called when the action prompt is rendered, providing the default for the "Nth" parameter of the corresponding action.

Ex 3.4: Modify the menu action to create PetOwners

If we want to create a new PetOwner and provide their firstName, at the moment it’s a two stage process: create the PetOwner (using PetOwners#create action from the menu), then update their name (using the updateName action that we just looked at).

In this exercise we’ll simplify that workflow by allowing the firstName to optionally be specified during the initial create.

Solution

git checkout tags/03-04-modifies-PetOwners-create-action
mvn clean install
mvn -pl spring-boot:run

Tasks

  • update Orders#create action, so that the end user can specify a firstName when creating a new PetOwner:

    PetOwners.java
    @Action(semantics = SemanticsOf.NON_IDEMPOTENT)
    @ActionLayout(promptStyle = PromptStyle.DIALOG_SIDEBAR)
    public PetOwner create(
            @LastName final String lastName,
            @FirstName final String firstName) {
        return repositoryService.persist(PetOwner.withName(lastName, firstName));
    }

Optional exercise

If you decide to do this optional exercise, make the changes on a git branch so that you can resume with the main flow of exercises later.

It would be nice if the PetOwner were identified by both their firstName and their lastName; at the moment every PetOwner must have a unique lastName. Or, even better would be to introduce some sort of "customerNumber" and use this as the unique identifier.

Ex 3.5: Initial Fixture Script

As we prototype with an in-memory database, it means that we need to setup the database each time we restart the application. Using the Prototyping  Fixture Scripts menu to setup data saves some time, but it would nicer still if that script could be run automatically. We can do that by specifying a configuration property.

We can also leverage Spring Boot profiles to keep this configuration separate.

Solution

git checkout tags/03-05-initial-fixture-script
mvn clean install
mvn -pl spring-boot:run

Tasks

  • create the following file in src/main/resources of the webapp (alongside the existing application.yml file):

    application-dev.yaml
    causeway:
      testing:
        fixtures:
          initial-script: petclinic.webapp.application.fixture.scenarios.PetClinicDemo
  • modify the startup of your application to enable this profile, using this system prpoerty:

    -Dspring.profiles.active=dev

When you run the application you should now find that there are 10 PetOwner objects already created.

Ex 3.6: Prompt styles

The framework provides many ways to customise the UI, either through the layout files or using the @XxxLayout annotations. Default UI conventions can also be specified using the application.yml configuration file.

In this exercise we’ll change the prompt style for both a service (menu) action, ie PetOwners#create, and an object action, ie PetOwner#updateName.

Solution

git checkout tags/03-06-prompt-styles
mvn clean install
mvn -pl spring-boot:run

Tasks

  • Service (menu) actions are always shown in a dialog, of which there are two styles: modal prompt, or sidebar. If not specified explicitly, they will default to dialog modal.

    Therefore remove the @ActionLayout(promptStyle) for PetOwners#create and confirm that the dialog is now shown as a modal prompt.

  • Object actions can be shown either inline or in a dialog, but default to inline. If forced to use a dialog, then they default to a sidebar prompt rather than a modal prompt.

    Therefore remove the @ActionLayout(promptStyle) for PetOwner#updateName and confirm that prompt is still inline.

  • Using a configuration property we can change the default for object actions to use a dialog rather than inline.

    using the Spring boot profile trick from before:

    application-custom.yaml
    causeway:
      viewer:
        wicket:
          prompt-style: dialog

    Remember to activate this new profile (-Dspring.profiles.active=dev,custom) and confirm that the updateName prompt now uses a sidebar dialog.

  • We can overide the default dialog style for both service and object actions using further configuration properties.

    Switch the defaults so that service actions prefer to use a sidebar dialog, while object actions would use a modal dialog:

    application-custom.yaml
    causeway:
      viewer:
        wicket:
          prompt-style: dialog
          dialog-mode: modal
          dialog-mode-for-menu: sidebar
  • Optional: now use @ActionLayout(promptStyle=…​) to override these defaults.

    Be aware that "inline" makes no sense/is not supported for service actions.

  • Finish off the exercises by setting up these defaults to retain the original behaviour:

    application-custom.yaml
    causeway:
      viewer:
        wicket:
          prompt-style: inline
          #dialog-mode: modal   # unused if prompt-style is inline
          dialog-mode-for-menu: sidebar

Ex 3.7: Derived name property

The PetOwner's firstName and lastName properties are updated using the updateName action, but when the action’s button is invoked, it only "replaces" the lastName property:

Owner updateName prompt

In this exercise we’ll improve the UI by introducing a derived name property and then hiding the firstName and lastName:

Owner name

When PetOwner#updateName is invoked, the prompt we’ll want see is:

Owner name updated

Solution

git checkout tags/03-07-derived-PetOwner-name
mvn clean install
mvn -pl spring-boot:run

Tasks

  • Add getName() as the derived name property:

    PetOwner.java
    @Transient
    @PropertyLayout(fieldSetId = "name", sequence = "1")
    public String getName() {
        return getFirstName() + " " + getLastName();
    }
  • Hide the lastName and firstName properties, using @Property(hidden=…​). We can also remove the @PropertyLayout annotation.

    PetOwner.java
    @LastName
    @Column(length = LastName.MAX_LEN, nullable = false)
    @Getter @Setter @ToString.Include
    @Property(hidden = Where.EVERYWHERE)
    private String lastName;
    
    @FirstName
    @Column(length = FirstName.MAX_LEN, nullable = true)
    @Getter @Setter @ToString.Include
    @Property(hidden = Where.EVERYWHERE)
    private String firstName;
  • Update the PetOwner#updateName to associate with the new name property:

    @ActionLayout(associateWith = "name",)
    public PetOwner updateName( ... ) {}

Run the application and check that it behaves as you expect.

However, if you now try to build the app (mvn clean install) then you’ll hit test errors, because we have changed the visibility of the lastName and firstName properties.

We will be looking at tests later on, so if you want to just comment out the failing tests, then do that. Alternatively, here are the changes that need to be made:

  • update the PetOwner_IntegTest#name nested static test class:

    PetOwner_IntegTest.java
    @Nested
    public static class name extends PetOwner_IntegTest {
    
        @Test
        public void accessible() {
            // when
            final String name = wrap(petOwner).getName();   (1)
    
            // then
            assertThat(name).isEqualTo(petOwner.getLastName());
        }
    
        (2)
    }
    1 change this line from getLastName() to getName()
    2 delete the 'editable' test
  • add a new PetOwner_IntegTest#lastName nested static test class to check that the lastName property can no longer be viewed:

    PetOwner_IntegTest.java
    @Nested
    public static class lastName extends PetOwner_IntegTest {
    
        @Test
        public void not_accessible() {
            // expect
            assertThrows(HiddenException.class, ()->{
    
                // when
                wrap(petOwner).getLastName();
            });
        }
    }

    This asserts that the lastName property cannot be viewed.

  • add a new PetOwner_IntegTest#firstName nested static test class to check that the firstName property can no longer be viewed.

    PetOwner_IntegTest.java
    @Nested
    public static class firstName extends PetOwner_IntegTest {
    
        @Test
        public void not_accessible() {
            // expect
            assertThrows(HiddenException.class, ()->{
    
                // when
                wrap(petOwner).getFirstName();
            });
        }
    }
  • update the PetOwner_IntegTest#updateName nested static test class, specifically the assertion:

    PetOwner_IntegTest.java
    @Nested
    public static class updateName extends PetOwner_IntegTest {
    
    
        @Test
        public void can_be_updated_directly() {
    
            // when
            wrap(petOwner).updateName("McAdam", "Adam");                (1)
            transactionService.flushTransaction();
    
            // then
            assertThat(petOwner.getLastName()).isEqualTo("McAdam");     (2)
            assertThat(petOwner.getFirstName()).isEqualTo("Adam");      (2)
        }
        //...
    }
    1 provide both lastName and firstName parameters
    2 assert on both properties. Note that the petOwner object cannot be "wrapped".

In case you are wondering, the wrap method is a call to WrapperFactory, which provides a proxy to the object. This proxy emulates the UI, in this case enforcing the "hidden" rule by throwing an exception if it would not be visible. For this test, we want to peek under the covers to check the direct state of the entity, therefore we don’t wrap the object.

  • also update the Smoke_IntegTest:

    Smoke_IntegTest.java
    ...
    assertThat(wrap(fred).getName()).isEqualTo("Freddy"); (1)
    ...
    1 previously was "wrap(fred).getLastName().

Ex 3.8: Add other properties for PetOwner

Let’s add the two remaining properties for PetOwner:

Diagram

They are phoneNumber and emailAddress.

Solution

git checkout tags/03-08-add-remaining-PetOwner-properties
mvn clean install
mvn -pl spring-boot:run

Task

  • Create a @PhoneNumber meta-annotation, defined to be an editable property:

    PhoneNumber.java
    @Property(
            editing = Editing.ENABLED,  (1)
            maxLength = PhoneNumber.MAX_LEN,
            optionality = Optionality.OPTIONAL
    )
    @Parameter(maxLength = PhoneNumber.MAX_LEN, optionality = Optionality.OPTIONAL)
    @Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
    @Retention(RetentionPolicy.RUNTIME)
    public @interface PhoneNumber {
    
        int MAX_LEN = 30;
    }
    1 any properties annotated with this meta-annotation will be editable by default
  • Similarly, create an @EmailAddress meta-annotation, defined to be an editable property:

    EmailAddress.java
    @Property(
            editing = Editing.ENABLED,
            maxLength = EmailAddress.MAX_LEN,
            optionality = Optionality.OPTIONAL
    )
    @PropertyLayout(named = "E-mail")   (1)
    @Parameter(maxLength = EmailAddress.MAX_LEN, optionality = Optionality.OPTIONAL)
    @ParameterLayout(named = "E-mail")  (2)
    @Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
    @Retention(RetentionPolicy.RUNTIME)
    public @interface EmailAddress {
    
        int MAX_LEN = 100;
    }
    1 @PropertyLayout#named allows characters to be used that are not valid Java identifiers.
    2 @ParameterLayout#named - ditto.
  • add properties to PetOwner:

    PetOwner.java
    @PhoneNumber
    @Column(length = PhoneNumber.MAX_LEN, nullable = true)
    @PropertyLayout(fieldSetId = "name", sequence = "1.5")
    @Getter @Setter
    private String phoneNumber;
    
    @EmailAddress
    @Column(length = EmailAddress.MAX_LEN, nullable = true)
    @PropertyLayout(fieldSetId = "name", sequence = "1.6")
    @Getter @Setter
    private String emailAddress;

Ex 3.9: Validation

At the moment there are no constraints for the format of phoneNumber or emailAddress properties. We can fix this by adding rules to their respective meta-annotations.

git checkout tags/03-09-validation-rules-using-metaannotations
mvn clean install
mvn -pl spring-boot:run

Task

  • Update the @Property annotation of the @PhoneNumber meta-annotation:

    PhoneNumber.java
    @Property(
            editing = Editing.ENABLED,
            maxLength = PhoneNumber.MAX_LEN,
            optionality = Optionality.OPTIONAL,
            regexPattern = "[+]?[0-9 ]+",       (1)
            regexPatternReplacement =           (2)
                "Specify only numbers and spaces, optionally prefixed with '+'.  " +
                "For example, '+353 1 555 1234', or '07123 456789'"
    )
    @Parameter(maxLength = PhoneNumber.MAX_LEN, optionality = Optionality.OPTIONAL)
    @Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
    @Retention(RetentionPolicy.RUNTIME)
    public @interface PhoneNumber {
    
        int MAX_LEN = 30;
    }
    1 regex constraint
    2 validation message if the constraint is not met
  • Similarly, update @EmailAddress:

    EmailAddress.java
    @Property(
            editing = Editing.ENABLED,
            maxLength = EmailAddress.MAX_LEN,
            optionality = Optionality.OPTIONAL,
            regexPattern = "[^@]+@[^@]+[.][^@]+",                   (1)
            regexPatternReplacement = "Invalid email address"       (2)
    )
    @PropertyLayout(named = "E-mail")
    @Parameter(maxLength = EmailAddress.MAX_LEN, optionality = Optionality.OPTIONAL)
    @ParameterLayout(named = "E-mail")
    @Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
    @Retention(RetentionPolicy.RUNTIME)
    public @interface EmailAddress {
    
        int MAX_LEN = 100;
    }
    1 regex constraint. (Should really use a more comprehensive regex, eg see https://emailregex.com).
    2 validation message if the constraint is not met

Try out the application and check that these rules are applied.

The updateName action also has a validation rule, applied directly to the method:

PetOwner.java
public String validate0UpdateName(String newName) {             (1)
    for (char prohibitedCharacter : "&%$!".toCharArray()) {
        if( newName.contains(""+prohibitedCharacter)) {
            return "Character '" + prohibitedCharacter + "' is not allowed.";
        }
    }
    return null;
}
1 validates the "0th" parameter of updateName. More details on the validate supporting method can be found here.

We can Move this constraint onto the @LastName meta-annotation instead:

  • Update the @LastName meta-annotation using a Specification:

    LastName.java
    @Property(maxLength = LastName.MAX_LEN, mustSatisfy = LastName.Spec.class)  (1)
    @Parameter(maxLength = LastName.MAX_LEN, mustSatisfy = LastName.Spec.class) (1)
    @ParameterLayout(named = "Last Name")
    @Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
    @Retention(RetentionPolicy.RUNTIME)
    public @interface LastName {
    
        int MAX_LEN = 40;
    
        class Spec extends AbstractSpecification<String> {                      (2)
            @Override public String satisfiesSafely(String candidate) {
                for (char prohibitedCharacter : "&%$!".toCharArray()) {
                    if( candidate.contains(""+prohibitedCharacter)) {
                        return "Character '" + prohibitedCharacter + "' is not allowed.";
                    }
                }
                return null;
            }
        }
    }
    1 indicates that the property or parameter value must satisfy the specification below
    2 defines the specification definition, where a non-null value is the reason why the specification is not satisfied.
  • Remove the validate0UpdateName from PetOwner.

Test the app once more.

Optional exercise

If you decide to do this optional exercise, make the changes on a git branch so that you can resume with the main flow of exercises later.

As well as validating the lastName, it would be nice to also validate firstName with the same rule. As the logic is shared, create a new meta-(meta-)annotation called @Name, move the specification (and anything else that is common between lastName and firstName) to that new meta annotation, and then meta-annotate @LastName and @FirstName with @Name.

Ex 3.10: Field layout

At the moment all the properties of PetOwner are grouped into a single fieldset. The UI would be improved by grouping properties according to their nature, for example the "phoneNumber" and "emailAddress" in a "Contact Details" fieldset.

We do this using the associated PetOwner.layout.xml file (which defines the positioning of the fieldsets), and also using the annotations within PetOwner (which associate the properties to those fieldsets).

Solution

git checkout tags/03-10-PetOwner-fieldsets
mvn clean install
mvn -pl spring-boot:run

Task

  • modify the PetOwner.layout.xml, adding two new fieldSet definitions after the first tabGroup:

    PetOwner.layout.xml
    <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
    <bs:grid>
        <bs:row>
            <!-- ... -->
        </bs:row>
        <bs:row>
            <bs:col span="6">
                <bs:tabGroup>
                    <!-- ... -->
                </bs:tabGroup>
                <c:fieldSet id="contactDetails" name="Contact Details"/> (1)
                <c:fieldSet id="notes" name="Notes"/>                    (2)
            </bs:col>
            <bs:col span="6">
                <!-- ... -->
            </bs:col>
        </bs:row>
    </bs:grid>
    1 fieldSet for contact details
    2 fieldset for the notes
  • modify the @PropertyLayout annotation for the properties to associate with these fieldsets:

    PetOwner.java
    // ...
    @PropertyLayout(fieldSetId = "contactDetails", sequence = "1")    (1)
    private String phoneNumber;
    
    // ...
    @PropertyLayout(fieldSetId = "contactDetails", sequence = "2")    (2)
    private String emailAddress;
    
    // ...
    @PropertyLayout(fieldSetId = "notes", sequence = "1")               (3)
    private String notes;
    1 associates as the 1st property in the "contact details" fieldset
    2 associates as the 2nd property in the "contact details" fieldset
    3 associates with the "notes" fieldset

Run the application; the layout should look like:

fieldsets

The layout file can be reloaded dynamically (on IntelliJ, Run  Debugging Actions  Reload Changed Classes), so you can inspect any updates without having to restart the app. Experiment with this by moving a fieldset into a tab group, or change the width of a column).

Optional Exercise

If you decide to do this optional exercise, make the changes on a git branch so that you can resume with the main flow of exercises later.

It is also possible to associate the properties to fieldsets using only the .layout.xml file. In fact, pretty much all of the metadata in the @XxxLayout annotations can be specified in the layout file.

PetOwner.layout.xml
<c:fieldSet id="contactDetails" name="Contact Details">
    <c:property id="phoneNumber"/>
    <c:property id="emailAddress"/>
</c:fieldSet>
<c:fieldSet id="notes" name="Notes">
    <c:property id="notes"/>
</c:fieldSet>

The @PropertyLayout annotations could then be removed.

Using the layout file to specify individual properties provides even more fine-grained control when dynamically reloading, so you could for example switch the order of properties in a fieldset and inspect the changes immediately without having to restart the app. You might find though that the main benefit of the layout file is to declare how the different "regions" of the UI fit together in terms of rows, columns, tabs and fieldsets, and then use annotations to slot the properties/actions into those regions. It really is a matter of personal preference which approach you use.

Ex 3.11: Column Orders

The home page of the webapp shows a list of all `PetOwner`s (inherited from the original simple app). We also see a list of `PetOwner`s if we invoke Pet Owners  List All.

The first is a "parented" collection (it is parented by the home page view model), the second is a standalone collection (it is returned from an action).

The properties that are shown as columns that are shown is based on two different mechanisms. The first is whether the property is visible at all in any tables, which can be specified using @PropertyLayout(hidden=…​) (see @PropertyLayout#hidden). The second is to use a "columnOrder" file.

In this exercise, we’ll use the latter approach.

Solution

git checkout tags/03-11-PetOwner-columnOrder
mvn clean install
mvn -pl spring-boot:run

Task

  • Declare the id field of PetOwner as a property by adding a getter and other annotations:

    PetOwner.java
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "id", nullable = false)
    @Getter @Setter                                             (1)
    @PropertyLayout(fieldSetId = "metadata", sequence = "1")    (2)
    private Long id;
    1 makes field available as a property
    2 positions property in the metadata fieldset (before version).
  • update the columnOrder for standalone collections of PetOrder:

    PetOwner.columnOrder.txt
    name
    id
    #version

    This will show only name and id; none of the other properties will be visible as columns.

  • create a new file HomePageViewModel#objects.columnOrder.txt (in the same package as HomePageViewModel) to define the columns visible in the objects collection of that view model:

    HomePageViewModel#objects.columnOrder.txt
    name
    id
    #version
  • delete the (unused) PetOwner#others.columnOrder.txt file.

Run the application and confirm the columns are as expected. You should also be able to update the files and reload changes (on IntelliJ, Run  Debugging Actions  Reload Changed Classes) and inspect the updates without having to restart the app.