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
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#name
→PetOwner#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 renamedThis also requires updating the
menubars.layout.xml
, which references these action names. -
updating the @ActionLayout of the
updateName
anddelete
action methods inPetOwner
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 PetOwner
s) 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
toPetOwner
:@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.javapublic 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 fromlastName
property, and add atitle()
method to derive from both properties:PetOwner.javapublic 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 newfirstName
parameter: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.javapublic 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 afirstName
when creating a newPetOwner
: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
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.
Tasks
-
create the following file in
src/main/resources
of the webapp (alongside the existingapplication.yml
file):application-dev.yamlisis: 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
.
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)
forPetOwners#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)
forPetOwner#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.yamlisis: viewer: wicket: prompt-style: dialog
Remember to activate this new profile (
-Dspring.profiles.active=dev,custom
) and confirm that theupdateName
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.yamlisis: 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.yamlisis: 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:
In this exercise we’ll improve the UI by introducing a derived name
property and then hiding the firstName
and lastName
:
When PetOwner#updateName
is invoked, the prompt we’ll want see is:
Tasks
-
Add
getName()
as the derivedname
property:PetOwner.java@Transient @PropertyLayout(fieldSetId = "name", sequence = "1") public String getName() { return getFirstName() + " " + getLastName(); }
-
Hide the
lastName
andfirstName
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 newname
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()
togetName()
2 delete the 'editable' test -
add a new
PetOwner_IntegTest#lastName
nested static test class to check that thelastName
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 thefirstName
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
andfirstName
parameters2 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
:
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:
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
fromPetOwner
.
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).
Task
-
modify the
PetOwner.layout.xml
, adding two newfieldSet
definitions after the firsttabGroup
:PetOwner.layout.xml<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <bs3:grid> <bs3:row> <!-- ... --> </bs3:row> <bs3:row> <bs3:col span="6"> <bs3:tabGroup> <!-- ... --> </bs3:tabGroup> <c:fieldSet id="contactDetails" name="Contact Details"/> (1) <c:fieldSet id="notes" name="Notes"/> (2) </bs3:col> <bs3:col span="6"> <!-- ... --> </bs3:col> </bs3:row> </bs3: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:

The layout file can be reloaded dynamically (on IntelliJ,
), 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.
<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
.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.
Task
-
Declare the
id
field ofPetOwner
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.txtname id #version
This will show only
name
andid
; none of the other properties will be visible as columns. -
create a new file
HomePageViewModel#objects.columnOrder.txt
(in the same package asHomePageViewModel
) to define the columns visible in theobjects
collection of that view model:HomePageViewModel#objects.columnOrder.txtname 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,
) and inspect the updates without having to restart the app.