Adding further business logic - Worked Examples
Let’s remind ourselves of the original use cases we identified; some of these have been implemented already (admittedly, not all with tests around them):
-
create an
Owner
: yes, implemented -
add and remove
Pet
s for saidOwner
: yes, implemented. -
book a
Pet
in for aVisit
: yes, implemented. -
enter an
outcome
andcost
of aVisit
: not yet -
allow an
Owner
to pay for aVisit
: not yet -
find
Visit
s not yet paid and overdue (more than 28 days old): not yet -
delete an
Owner
and itsPet
s andVisit
s, so long as there are no unpaidVisit
s: partly. We currently just delete everything.
In this section we’ll implement the missing functionality, along with unit or integration tests as necessary.
Enter an outcome
An outcome for a Visit
consists of a diagnosis, and also the cost to be paid by the Pet
's Owner
.
Exercise
-
add a new integration test,
Visit_enterOutcome_IntegTest
,public class Visit_enterOutcome_IntegTest extends PetClinicModuleIntegTestAbstract { Visit visit; @Before public void setup() { // given Owner owner = runBuilderScript(Owner_enum.JOHN_SMITH); Pet pet = owner.getPets().first(); visit = wrap(mixin(Pet_visits.class, pet)).coll().iterator().next(); } @Test public void happy_case() { // when String diagnosis = someRandomDiagnosis(); BigDecimal cost = someRandomCost(); wrap(visit).enterOutcome(diagnosis, cost); // then assertThat(visit.getDiagnosis()).isEqualTo(diagnosis); assertThat(visit.getCost()).isEqualTo(cost); } private BigDecimal someRandomCost() { return new BigDecimal(20.00 + fakeDataService.doubles().upTo(30.00d)); } private String someRandomDiagnosis() { return fakeDataService.lorem().paragraph(3); } @Inject FakeDataService fakeDataService; }
-
in
Visit
, add in the two new properties and action.@Action(semantics = SemanticsOf.IDEMPOTENT) public Visit enterOutcome( @Parameter(maxLength = 4000) @ParameterLayout(multiLine = 5) final String diagnosis, final BigDecimal cost) { this.diagnosis = diagnosis; this.cost = cost; return this; } @javax.jdo.annotations.Column(allowsNull = "true", length = 4000) @Property(editing = Editing.DISABLED, editingDisabledReason = "Use 'enter outcome' action") @PropertyLayout(multiLine = 5) @Getter @Setter private String diagnosis; @javax.jdo.annotations.Column(allowsNull = "true", length = 6, scale = 2) @Property(editing = Editing.DISABLED, editingDisabledReason = "Use 'enter outcome' action") @Getter @Setter private BigDecimal cost;
-
update
Visit.layout.xml
for the two new properties and action. -
add in some further integration tests to ensure that the properties cannot be edited directly:
@Test public void cannot_edit_outcome_directly() { // expecting expectedExceptions.expect(DisabledException.class); expectedExceptions.expectMessage("Use 'enter outcome' action"); // when String diagnosis = someRandomDiagnosis(); wrap(visit).setDiagnosis(diagnosis); } @Test public void cannot_edit_cost_directly() { // expecting expectedExceptions.expect(DisabledException.class); expectedExceptions.expectMessage("Use 'enter outcome' action"); // when BigDecimal cost = someRandomCost(); wrap(visit).setCost(cost); }
Pay for a visit
We’ll support this use case through a new action "paid", on the Visit
domain entity.
To support the testing (and with half an eye to a future use case) we’ll also implement a "findNotPaid" query on the Visits
repository domain service.
Exercise
Let’s first work on the happy case:
-
Update
Visit
with a newpaid()
action andpaidOn
property. Also injectClockService
:@Action(semantics = SemanticsOf.IDEMPOTENT) public Visit paid() { paidOn = clockService.now(); return this; } @javax.jdo.annotations.Column(allowsNull = "true") @Property(editing = Editing.DISABLED, editingDisabledReason = "Use 'paid' action") @Getter @Setter private LocalDate paidOn; ... @Inject ClockService clockService;
-
Update the
Visits
domain service repository to findVisit
s that haven’t been paid:@Programmatic public java.util.List<Visit> findNotPaid() { TypesafeQuery<Visit> q = isisJdoSupport.newTypesafeQuery(Visit.class); final QVisit cand = QVisit.candidate(); q = q.filter( cand.paidOn.eq(q.parameter("paidOn", LocalDateTime.class) ) ); return q.setParameter("paidOn", null) .executeList(); }
-
Extend
OwnerBuilderScript
so that all but the lastVisit
for eachOwner
'sPet
s has been paid.Add some further supporting methods:
private String someDiagnosis() { return fakeDataService.lorem().paragraph(fakeDataService.ints().between(1, 3)); } private BigDecimal someCost() { return new BigDecimal(20.00 + fakeDataService.doubles().upTo(30.00d)); }
In the
execute(…)
, update thefor
loop so that allVisit
s have an outcome and all but the last (for eachOwner
) has been paid:for (int i = 0; i < petDatum.numberOfVisits; i++) { ... LocalDateTime someTimeInPast = ... Visit visit = ... wrap(visit).enterOutcome(someDiagnosis(), someCost()); if(i != petDatum.numberOfVisits - 1) { setTimeTo(ec, someTimeInPast.plusDays(fakeDataService.ints().between(10,30))); wrap(visit).paid(); } }
Prevent payment for a visit twice
We’ve already seen that it’s possible to validate arguments to actions; for example that a Visit
can only be booked in the future.
But if a Visit
has already been paid for, then we don’t want the user to be able to even attempt to invoke the action.
The framework provides three different types of pre-condition checks:
-
"See it?" - should the action/property be visible at all, or has it been hidden?
-
"Use it" - if visible, then can the action/property be used or has it been disabled (greyed out)
-
"Do it" - if the action/property is ok to be used (action invoked/property edited) then are the proposed action arguments or new property value valid, or are they invalid?
Or in other words, "see it, use it, do it".
As with validation, disablement can be defined either declaratively (annotations) or imperatively (supporting methods). Let’s see how an imperative supporting method can be used to implement this particular requirement (that a visit can’t be paid for twice).
Exercise
-
update
Visit_pay_IntegTest
to ensure cannot enter into thepaidOn
property directly:@Test public void cannot_edit_paidOn_directly() { // expecting expectedExceptions.expect(DisabledException.class); expectedExceptions.expectMessage("Use 'paid on' action"); // when wrap(visit).setPaidOn(clockService.now()); }
-
now, add in the test that asserts that a
Visit
cannot be paid more than once:@Test public void cannot_pay_more_than_once() { // given wrap(visit).paid(); assertThat(visits.findNotPaid()).asList().doesNotContain(visit); // expecting expectedExceptions.expect(DisabledException.class); expectedExceptions.expectMessage("Already paid"); // when wrap(visit).paid(); }
-
and finally update
Visit
. This is done using a supporting method.public String disablePaid() { return getPaidOn() != null ? "Already paid": null; }
Find Visit
s not yet paid and overdue
In the previous scenario we implemented Visits#findNotPaid()
.
Since this is pretty important information, let’s surface that to the end-user by adding it to the home page dashboard.
We could also go a little further by allowing the user to use the dashboard to update visits that have been paid.
This is a good example of how a view model can support specific business processes, in this case saving the end-user from having to navigate down to each and every one of the Visit
s.
Exercise
-
update
Dashboard
:@CollectionLayout(defaultView = "table") public List<Visit> getOverdue() { List<Visit> notPaid = visits.findNotPaid(); LocalDateTime thirtyDaysAgo = clockService.nowAsLocalDateTime().minusDays(30); return notPaid.stream() .filter(x -> x.getVisitAt().isBefore(thirtyDaysAgo)) (1) .collect(Collectors.toList()); } @Action(semantics = SemanticsOf.IDEMPOTENT, associateWith = "overdue") (2) public Dashboard paid(List<Visit> visits) { for (Visit visit : visits) { if(visit.getPaidOn() == null) { visit.paid(); } } return this; } @javax.inject.Inject Visits visits; @javax.inject.Inject ClockService clockService;
1 An alternative (better?) design would have been to add a new query method in Visits
to find those overdue, avoiding the client-side filtering that we see above.2 The "associateWith" annotation results in checkboxes alongside the "overdue" collection, with the collection providing the set of values for the parameter. -
update
Dashboard.layout.xml
also -
write a new
Dashboard_paid_IntegTest
integration test:public class Dashboard_paid_IntegTest extends PetClinicModuleIntegTestAbstract { Dashboard dashboard; @Before public void setup() { // given runFixtureScript(new PersonaEnumPersistAll<>(Owner_enum.class)); dashboard = homePageProvider.dashboard(); } @Test public void happy_case() { // given List<Visit> overdue = dashboard.getOverdue(); assertThat(overdue).isNotEmpty(); // when wrap(dashboard).paid(overdue); // then List<Visit> overdueAfter = dashboard.getOverdue(); assertThat(overdueAfter).isEmpty(); for (Visit visit : overdue) { assertThat(visit.getDiagnosis()).isNotNull(); assertThat(visit.getPaidOn()).isNotNull(); } } @Inject HomePageProvider homePageProvider; }
-
Running the integration test at this point will produce a null pointer exception. That’s because the framework has had no opportunity to inject any domain services into the
Dashboard
.Under normal runtime cases this doesn’t matter because the only caller of the method is the framework itself, and when the domain object is rendered the framework will automatically ensure that any domain sevices are injected.
In an integration test this doesn’t occur, and so we need to manually inject the services. It makes most sense to do this in
HomePageProvider
; we use the framework-providedServiceRegistry2
domain service:@HomePage public Dashboard dashboard() { return serviceRegistry2.injectServicesInto(new Dashboard()); } @Inject ServiceRegistry2 serviceRegistry2;
Digression: Hiding Columns in Tables
We could improve the dashboard a little. After all, in the "overdue" collection there’s no point in showing the "paidOn"; the value will always be null. Also, the "reason" column is also somewhat superfluous (as, arguably, is the "diagnosis" column):
The framework offers two different ways to address this, so we’ll show both.
Exercise
-
The first technique is within the Java code; one could think of this as an implication within the "application layer".
We use a domain service that implements
TableColumnOrderService
as an SPI to "advise" the framework on how to render the collection. Traditionally such classes are implemented as a nested static class, in this case ofDashboard
:@DomainService(nature = NatureOfService.DOMAIN) public static class RemovePaidOnFromOverdue extends TableColumnOrderService.Default { @Override public List<String> orderParented( final Object parent, final String collectionId, final Class<?> collectionType, final List<String> propertyIds) { if (parent instanceof Dashboard && "overdue".equalsIgnoreCase(collectionId)) { propertyIds.remove("paidOn"); } return propertyIds; } }
The above code removes the "paidOn" column.
-
The second technique is to exploit the fact that the HTML generated by the framework is liberally annotated with domain class identifiers. The column can therefore be removed by supplying the appropriate CSS. We could think of this as an implementation within the presentation layer.
In the
src/main/webapp/css/application.css
file, add:.domainapp-modules-impl-dashboard-Dashboard .entityCollection .overdue .Visit-reason { display: none; }
Another Digression: Icons and CSS
In the same way that titles can be specified imperatively, so too can icons, using the iconName()
method.
One use case is for a domain object that has several states: the iconName()
defines a suffix which is used to lookup different icons (eg "ToDoItem-notDone.png" and "ToDoItem-done.png").
Similarly, it’s possible to specify CSS hints imperatively using the cssClass()
.
This returns a simple string that is added as a CSS class wherever the object is rendered in the UI.
In this exercise we’ll use a different icon for the various species of Pet
:
Let’s also use a strike-through text for all Visit
s that are paid when rendered within a collection:
Exercise
For the icons:
-
add new icons for each of the pet species:
Pet-dog.png
,Pet-cat.png
,Pet-hamster.dog
andPet-budgerigar.png
-
add an
iconName()
method toPet
:public String iconName() { return getPetSpecies().name().toLowerCase(); }
For the CSS class:
-
add a
cssClass()
method toVisit
:public String cssClass() { boolean isPaid = getPaidOn() != null; return isPaid ? "paid": null; }
-
update
application.css
:
.entityCollection .domainapp-modules-impl-visits-dom-Visit .paid {
text-decoration: line-through;
color: lightgrey;
}
Delete an Owner
provided no unpaid Visit
s
Solution
git checkout tags/390-delete-an-owner-provided-no-unpaid-visits
mvn clean package jetty:run
Exercise
We don’t want Owner
(in the pets
module) to check for unpaid Visit
s, because that would create a cyclic dependency between modules.
Instead, we’ll use a subscriber in the visits
module which can veto any attempt to delete an owner if there are unpaid visits.
For this, we arrange for the Owner
to emit an action domain event when its delete()
action is invoked.
In fact, the event will be emitted by the framework up to five times: to check if the action is visible, if it is disabled, if it’s valid, pre-execute and post-execute.
The subscriber in the visits
module will therefore potentially veto on the disable phase.
-
in the
Visits
repository, addfindNotPaidBy
method to find any unpaidVisit
s for anOwner
:@Programmatic public java.util.List<Visit> findNotPaidBy(Owner owner) { TypesafeQuery<Visit> q = isisJdoSupport.newTypesafeQuery(Visit.class); final QVisit cand = QVisit.candidate(); q = q.filter( cand.paidOn.eq(q.parameter("paidOn", LocalDateTime.class) ).and( cand.pet.owner.eq(q.parameter("owner", Owner.class)) ) ); return q.setParameter("paidOn", null) .setParameter("owner", owner) .executeList(); }
-
update
Owner’s `delete()
action so that it emits an action domain event.import org.apache.isis.applib.services.eventbus.ActionDomainEvent; ... public static class Delete extends ActionDomainEvent<Owner> {} (1) @Action( domainEvent = Delete.class (2) semantics = SemanticsOf.NON_IDEMPOTENT (3) ) public void delete() { final String title = titleService.titleOf(this); messageService.informUser(String.format("'%s' deleted", title)); repositoryService.removeAndFlush(this); }
1 declare the event, and 2 emit it 3 change from NON_IDEMPOTENT_ARE_YOU_SURE
(due to a bug in the framework). -
add a new integration test:
public class Owner_delete_IntegTest extends PetClinicModuleIntegTestAbstract { @Test public void can_delete_if_there_are_no_unpaid_visits() { // given runFixtureScript(Owner_enum.FRED_HUGHES.builder()); Owner owner = Owner_enum.FRED_HUGHES.findUsing(serviceRegistry); List<Visit> any = visits.findNotPaidBy(owner); assertThat(any).isEmpty(); // when wrap(owner).delete(); // then Owner ownerAfter = Owner_enum.FRED_HUGHES.findUsing(serviceRegistry); assertThat(ownerAfter).isNull(); } @Test public void cannot_delete_with_unpaid_visits() { // given runFixtureScript(Owner_enum.MARY_JONES.builder()); Owner owner = Owner_enum.MARY_JONES.findUsing(serviceRegistry); List<Visit> any = visits.findNotPaidBy(owner); assertThat(any).isNotEmpty(); // expect expectedExceptions.expect(DisabledException.class); expectedExceptions.expectMessage("This owner still has unpaid visit(s)"); // when wrap(owner).delete(); } @Inject Visits visits; }
-
add the subscriber to veto the action if required:
@DomainService(nature = NatureOfService.DOMAIN) public class VetoDeleteOfOwnerWithUnpaidVisits extends org.apache.isis.applib.AbstractSubscriber { @org.axonframework.eventhandling.annotation.EventHandler public void on(Owner.Delete ev) { switch (ev.getEventPhase()) { case DISABLE: Collection<Visit> visitsForPet = visits.findNotPaidBy(ev.getSource()); if (!visitsForPet.isEmpty()) { ev.veto("This owner still has unpaid visit(s)"); } break; } } @javax.inject.Inject Visits visits; }
-
finally, in
PetClinicModuleIntegTestAbstract
, we need to make a small adjustment to use the same event bus implementation as the production app:super(new PetClinicModule() .withAdditionalServices(DeploymentCategoryProviderForTesting.class) .withConfigurationProperty("isis.services.eventbus.implementation","axon") (1) .withConfigurationProperty(TranslationServicePo.KEY_PO_MODE, "write") );
1 specify Axon as the event bus implementation