You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@causeway.apache.org by da...@apache.org on 2024/05/26 23:02:22 UTC
(causeway) branch CAUSEWAY-2873 updated (ca0901e91e -> 09f6f2bf2b)
This is an automated email from the ASF dual-hosted git repository.
danhaywood pushed a change to branch CAUSEWAY-2873
in repository https://gitbox.apache.org/repos/asf/causeway.git
from ca0901e91e CAUSEWAY-2873: 06-01
new c5b1773e28 CAUSEWAY-2873: 07-01
new ea86cd2a23 CAUSEWAY-2873: 09-01
new 09f6f2bf2b CAUSEWAY-2873: 08-02
The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
.../modules/petclinic/pages/070-modularity.adoc | 153 ++++------
.../modules/petclinic/pages/080-view-models.adoc | 309 ++++++++-------------
.../petclinic/pages/090-integration-testing.adoc | 119 ++++----
.../modules/petclinic/pages/100-todo.adoc | 2 +
4 files changed, 236 insertions(+), 347 deletions(-)
(causeway) 01/03: CAUSEWAY-2873: 07-01
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
danhaywood pushed a commit to branch CAUSEWAY-2873
in repository https://gitbox.apache.org/repos/asf/causeway.git
commit c5b1773e2841168aec684c376e3385da99048a95
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Sun May 26 22:49:06 2024 +0100
CAUSEWAY-2873: 07-01
---
.../modules/petclinic/pages/070-modularity.adoc | 153 ++++++++-------------
.../modules/petclinic/pages/100-todo.adoc | 2 +
2 files changed, 62 insertions(+), 93 deletions(-)
diff --git a/antora/components/tutorials/modules/petclinic/pages/070-modularity.adoc b/antora/components/tutorials/modules/petclinic/pages/070-modularity.adoc
index 793a6aad36..fa827459db 100644
--- a/antora/components/tutorials/modules/petclinic/pages/070-modularity.adoc
+++ b/antora/components/tutorials/modules/petclinic/pages/070-modularity.adoc
@@ -8,29 +8,28 @@ If every class potentially can depend on any other class, we'll end up with a "b
Instead, we need to ensure that the dependency graph between packages remains acyclic.
The framework provides two main tools:
-* the first we've already seen is mixins.
+* the first we've already seen: mixins.
+
These allow us to locate business logic in one module that "appears" to reside in another module.
Examples are the `visits` mixin collection and `bookVisit` mixin action that are both contributed by the `visits` module to the `Pet` entity in the `pets` module.
* the second is domain events.
+
-These we haven't yet seen, but provide a way for one module to react to (or to veto) actions performed in logic in another module.
+These provide a way for one module to react to (or to veto) actions performed in logic in another module.
-In this part of the tutorial we'll look at domain events.
+In this section we'll look at domain events.
[#exercise-7-1-refactor-petowners-delete-action]
== Ex 7.1: refactor PetOwner's delete action
-Currently the `delete` action for `PetOwner` is implemented as a mixin within the `Pet` package.
-That's a nice place for that functionality, because it can delete any `Pet`s for the `PetOwner` if any exist.
+Currently the `delete` action for `PetOwner` is broken: although the owner's ``Pet``s are automatically deleted when the `PetOwner` is itself deleted, if there are any ``Visit``s then the foreign key in the database will prevent deletion.
-However, we also have added `Visit`, which has the same issue: we cannot delete a `Pet` if there are associated ``Visit``s.
-And, in fact, we don't want to allow a `PetOwner` and their ``Pet``s from being deleted if there are ``Visit``s in the database; they might not have paid!
+In one sense this is good: we probably don't want to allow a `PetOwner` and their ``Pet``s from being deleted if there are ``Visit``s in the database; they might not have paid!
+However, we ought to have the business logic in the domain layer rather than rely on the database's foreign key.
-In this exercise we will move the responsibility to delete an action back to `PetOwner`, and then use subscribers for both `Pet` and `Visit` to cascade delete or to veto the action respectively if there are related objects.
+In this exercise we'll use domain events to cascade delete or to veto the action respectively if there are related objects.
@@ -43,116 +42,84 @@ mvn clean install
mvn -pl spring-boot:run
----
-To test this out:
-
-* try deleting a `PetOwner` where none of their ``Pet``s have any ``Visit``s; the action should succeed, and the `PetOwner` and the ``Pet``s should all be deleted.
-
-* now book a `Visit` for a `Pet`, then navigate back to the parent `PetOwner` and attempt to delete it.
-This time the action should be vetoed, because of that `Visit`.
-
-
=== Tasks
-* in `PetOwner_delete` remove the code that deletes the ``Pet``s.
-In its place, define a subclass of xref:refguide:applib-classes:events.adoc#domain-event-classes[ActionDomainEvent] as a nested class of the mixin, and reference in the xref:refguide:applib:index/annotation/Action.adoc#domainEvent[@Action#domainEvent] attribute.
+* (optional) confirm that although it's not possible to delete a `PetOwner` if there are corresponding ``Visit``s, the error we get back is a database exception
+
+* in `PetOwner`, modify the `delete` action so that it emits a specific domain event type.
+
[source,java]
-.PetOwner_delete.java
+.PetOwner.java
----
-@Action(
- domainEvent = PetOwner_delete.ActionEvent.class, // <.>
- semantics = SemanticsOf.NON_IDEMPOTENT_ARE_YOU_SURE,
- commandPublishing = Publishing.ENABLED,
- executionPublishing = Publishing.ENABLED
-)
-@ActionLayout(
- associateWith = "name", position = ActionLayout.Position.PANEL,
- describedAs = "Deletes this object from the persistent datastore")
-@RequiredArgsConstructor
-public class PetOwner_delete {
-
- public static class ActionEvent // <.>
- extends ActionDomainEvent<PetOwner_delete>{}
-
- private final PetOwner petOwner;
-
- public void act() {
- repositoryService.remove(petOwner);
- return;
- }
-
- @Inject RepositoryService repositoryService;
-}
+ public static class DeleteActionDomainEvent
+ extends org.apache.causeway.applib.events.domain.ActionDomainEvent<PetOwner> {} // <.>
+
+ @Action(
+ semantics = NON_IDEMPOTENT_ARE_YOU_SURE,
+ domainEvent = DeleteActionDomainEvent.class // <.>
+ )
+ @ActionLayout(
+ describedAs = "Deletes this object from the persistent datastore")
+ public void delete() { ... }
----
<.> specifies the domain event to emit when the action is called
<.> declares the action event (as a subclass of the framework's xref:refguide:applib-classes:events.adoc#domain-event-classes[ActionDomainEvent]).
++
+NOTE: in fact, domain events are always emitted; but by default a generic `ActionDomainEvent` is used rather than a specific subclass.
* create a subscriber in the `pets` package to delete all ``Pet``s when the `PetOwner_delete` action is invoked:
+
[source,java]
-.PetOwnerForPetsSubscriber.java
+.PetOwner_delete_subscriber.java
----
-@Service
-public class PetOwnerForPetsSubscriber {
-
- @EventListener(PetOwner_delete.ActionEvent.class)
- public void on(PetOwner_delete.ActionEvent ev) {
- switch(ev.getEventPhase()) {
- case EXECUTING: // <.>
- PetOwner petOwner = ev.getSubject(); // <.>
- List<Pet> pets = petRepository.findByPetOwner(petOwner);
- pets.forEach(repositoryService::remove);
+@Component
+public class PetOwner_delete_subscriber {
+
+ @EventListener(PetOwner.DeleteActionDomainEvent.class) // <.>
+ void on(PetOwner.DeleteActionDomainEvent event) { // <1>
+ PetOwner subject = event.getSubject(); // <.>
+ switch (event.getEventPhase()) { // <.>
+ case HIDE:
break;
- }
- }
-
- @Inject PetRepository petRepository;
- @Inject RepositoryService repositoryService;
-}
-----
-<.> events are emitted at different phases.
-The `EXECUTING` phase is fired before the delete action itself is fired, so is the ideal place for us to perform the cascade delete.
-<.> is the mixee of the mixin that is emitting the event.
-
-* create a subscriber in the `visits` module to veto the `PetOwner_delete` if there are any `Pet`s of the `PetOwner` with at least one `Visit`:
-+
-[source,java]
-.PetOwnerForVisitsSubscriber.java
-----
-@Service
-public class PetOwnerForVisitsSubscriber {
-
- @EventListener(PetOwner_delete.ActionEvent.class)
- public void on(PetOwner_delete.ActionEvent ev) {
- switch(ev.getEventPhase()) {
- case DISABLE:
- PetOwner petOwner = ev.getSubject();
- List<Pet> pets = petRepository.findByPetOwner(petOwner);
- for (Pet pet : pets) {
- List<Visit> visits = visitRepository.findByPetOrderByVisitAtDesc(pet);
- int numVisits = visits.size();
- if(numVisits > 0) {
- ev.disable(String.format("%s has %d visit%s",
- titleService.titleOf(pet),
- numVisits,
- numVisits != 1 ? "s" : ""));
- }
+ case DISABLE: // <.>
+ List<Visit> visits = visitRepository.findByPetOwner(subject);
+ if (!visits.isEmpty()) {
+ event.veto("This owner has %d visit%s", visits.size(), (visits.size() == 1 ? "" : "s"));
}
break;
+ case VALIDATE:
+ break;
+ case EXECUTING:
+ break;
+ case EXECUTED:
+ break;
}
}
- @Inject TitleService titleService;
@Inject VisitRepository visitRepository;
- @Inject PetRepository petRepository;
}
----
+<.> subscribes to the event using Spring `@EventListener`
+<.> returns the effective originator of the event.
+This works for both regular actions and mixin actions
+<.> the subscriber is called multiple times, for the various phases of the execution lifecycle; more on this below
+<.> if there are any ``Visit``s for this pet owner, then veto the interaction.
+In the user interface, the "delete" button will be disabled, that is greyed out.
+The returned string is used as the tooltip to explain _why_ the button is disabled.
+The event lifecycle allows subscribers to veto (in other words, specify preconditions) in three different ways:
-=== Optional Exercise
-
+* hide - will hide the action's button in the UI completely
+* disable - will disable (grey) out the action's button
+* validate - will prevent the button from being pressed.
+this case applies when validating the action arguments.
-Improve the implementation of `PetOwnerForVisitsSubscriber` so that it performs only a single database query to find if there are any ``Visit`` for the `PetOwner`.
+If the action is not vetored, then the subscriber is also possible to perform additional steps:
+* executing - the action is about to execute.
+* executed - the action is just execute
+For example, if the business rule had instead been to simply delete all ``Visit``s, then this could have been implemented in the "executing" phase.
+TIP: it's also worth knowing that these events are fired for properties and collections as well as actions.
+Therefore subscribers can substantially dictate what is accessible for any given domain object.
diff --git a/antora/components/tutorials/modules/petclinic/pages/100-todo.adoc b/antora/components/tutorials/modules/petclinic/pages/100-todo.adoc
index 5ce9d43c8d..13156e85d2 100644
--- a/antora/components/tutorials/modules/petclinic/pages/100-todo.adoc
+++ b/antora/components/tutorials/modules/petclinic/pages/100-todo.adoc
@@ -8,6 +8,8 @@ validate pet name is unique within Pet
refactor addPet to be an inline-mixin.
+need a disable and a hide action
+
update home page, show upcoming appointments
(causeway) 02/03: CAUSEWAY-2873: 09-01
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
danhaywood pushed a commit to branch CAUSEWAY-2873
in repository https://gitbox.apache.org/repos/asf/causeway.git
commit ea86cd2a236d68564db6536936e83399792fb992
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Sun May 26 23:16:56 2024 +0100
CAUSEWAY-2873: 09-01
but skipped 08 accidentally
---
.../petclinic/pages/090-integration-testing.adoc | 119 +++++++++------------
1 file changed, 51 insertions(+), 68 deletions(-)
diff --git a/antora/components/tutorials/modules/petclinic/pages/090-integration-testing.adoc b/antora/components/tutorials/modules/petclinic/pages/090-integration-testing.adoc
index 425257ee68..b79e5e6e01 100644
--- a/antora/components/tutorials/modules/petclinic/pages/090-integration-testing.adoc
+++ b/antora/components/tutorials/modules/petclinic/pages/090-integration-testing.adoc
@@ -4,8 +4,9 @@
In an earlier section of this tutorial we looked at unit testing, but integration tests are at least as important, probably more so, as they exercise the entire application from an end-users perspective, rather than an individual part.
-Integration tests are _not_ written using Selenium or similar, so avoid the fragility and maintenance effort that such tests often entail.
+We don't write integration tests using Selenium or similar, and so avoid the fragility and maintenance effort that such tests often entail.
Instead, the framework provides the xref:refguide:applib:index/services/wrapper/WrapperFactory.adoc[WrapperFactory] domain service which simulates the user interface in a type-safe way.
+Another term sometimes used is _subcutaneous_ testing; we execute the test "under the skin".
[#exercise-9-1-testing-bookvisit-using-an-integtest]
@@ -24,29 +25,35 @@ mvn clean package jetty:run
=== Tasks
-* in the `pom.xml` of the visits module, add the following dependency:
+* in the `pom.xml` of the visits module, add the following dependencies:
+
[source,xml]
.module-visits/pom.xml
----
<dependency>
- <groupId>org.apache.causeway.mavendeps</groupId>
- <artifactId>causeway-mavendeps-integtests</artifactId>
- <type>pom</type>
+ <groupId>org.apache.causeway.testing</groupId>
+ <artifactId>causeway-testing-integtestsupport-applib</artifactId>
+ <scope>test</scope>
+</dependency>
+
+<dependency>
+ <groupId>org.apache.causeway.persistence</groupId>
+ <artifactId>causeway-persistence-jpa-eclipselink</artifactId>
<scope>test</scope>
</dependency>
----
-* add an abstract class `VisitsModuleIntegTestAbstract` for the `visits` module, for other integ tests to subclass:
+
+* add an abstract class `VisitModuleIntegTestAbstract` for the `visits` module, for other integ tests to subclass:
+
[source,java]
-.VisitsModuleIntegTestAbstract.java
+.VisitModuleIntegTestAbstract.java
----
@SpringBootTest(
- classes = VisitsModuleIntegTestAbstract.TestApp.class
+ classes = VisitModuleIntegTestAbstract.TestApp.class
)
@ActiveProfiles("test")
-public abstract class VisitsModuleIntegTestAbstract
+public abstract class VisitModuleIntegTestAbstract
extends CausewayIntegrationTestAbstractWithFixtures {
@SpringBootConfiguration
@@ -58,7 +65,7 @@ public abstract class VisitsModuleIntegTestAbstract
CausewayModulePersistenceJpaEclipselink.class,
CausewayModuleTestingFixturesApplib.class,
- VisitsModule.class
+ VisitModule.class // <.>
})
@PropertySources({
@PropertySource(CausewayPresets.H2InMemory_withUniqueSchema),
@@ -68,16 +75,17 @@ public abstract class VisitsModuleIntegTestAbstract
}
}
----
+<.> Most of this class is boilerplate, but it does reference the module under test.
-* also update the `application-test.yml` file for the `visits` module, to ensure that the database schemas for both modules are created:
+* also update the `application-test.yml` file for the `visit` module, to ensure that the database schemas for both modules are created:
+
[source,yaml]
-.module-visits/src/test/resources/application-test.yml
+.module-visit/src/test/resources/application-test.yml
----
causeway:
persistence:
schema:
- auto-create-schemas: pets,visits
+ auto-create-schemas: petowner,visit
----
* add a class `Bootstrap_IntegTest` integration test, inheriting from the `VisitsModuleIntegTestAbstract:
@@ -85,7 +93,7 @@ causeway:
[source,java]
.Bootstrap_IntegTest.java
----
-public class Bootstrap_IntegTest extends VisitsModuleIntegTestAbstract {
+public class Bootstrap_IntegTest extends VisitModuleIntegTestAbstract {
@Test
public void checks_can_bootstrap() {}
@@ -97,42 +105,46 @@ Make sure this test runs and passes in both the IDE and using "mvn clean install
Now we can write our actual test:
-* Now add a class `Pet_bookVisit_IntegTest` integration test, also inheriting from the `VisitsModuleIntegTestAbstract:
+* Now add a class `PetOwner_bookVisit_IntegTest` integration test, also inheriting from the `VisitModuleIntegTestAbstract:
+
[source,java]
-.Pet_bookVisit_IntegTest.java
+.PetOwner_bookVisit_IntegTest.java
----
-public class Pet_bookVisit_IntegTest extends VisitsModuleIntegTestAbstract {
+public class PetOwner_bookVisit_IntegTest extends VisitModuleIntegTestAbstract {
@BeforeEach
void setup() {
- fixtureScripts.run(new Pet_persona.PersistAll()); // <.>
+ fixtureScripts.run(new PetOwner_persona.PersistAll()); // <.>
}
@Test
public void happy_case() {
// given
- Pet somePet = fakeDataService.enums().anyOf(Pet_persona.class) // <.>
- .findUsing(serviceRegistry);
- List<Visit> before = visitRepository.findByPetOrderByVisitAtDesc(somePet);
+ PetOwner somePetOwner = fakeDataService.enums() // <.>
+ .anyOf(PetOwner_persona.class)
+ .findUsing(serviceRegistry);
+ Pet somePet = fakeDataService.collections()
+ .anyOf(somePetOwner.getPets());
+
+ List<Visit> before = visitRepository.findByPetOwner(somePetOwner);
+ assertThat(before).isEmpty();
// when
- LocalDateTime visitAt = clockService.getClock().nowAsLocalDateTime() // <.>
- .plusDays(fakeDataService.ints().between(1, 3));
- String reason = fakeDataService.strings().upper(40); // <3>
+ LocalDateTime visitAt = clockService.getClock().nowAsLocalDateTime() // <.>
+ .plusDays(fakeDataService.ints().between(1, 3));
- wrapMixin(Pet_bookVisit.class, somePet).act(visitAt, reason); // <.>
+ wrapMixin(PetOwner_bookVisit.class, somePetOwner).act(somePet, visitAt); // <.>
// then
- List<Visit> after = visitRepository.findByPetOrderByVisitAtDesc(somePet);
- after.removeAll(before);
- assertThat(after).hasSize(1); // <.>
+ List<Visit> after = visitRepository.findByPetOwner(somePetOwner);
+ assertThat(after).hasSize(1);
+
Visit visit = after.get(0);
- assertThat(visit.getPet()).isSameAs(somePet); // <.>
- assertThat(visit.getVisitAt()).isEqualTo(visitAt); // <6>
- assertThat(visit.getReason()).isEqualTo(reason); // <6>
+ assertThat(visit.getPet()).isSameAs(somePet); // <.>
+ assertThat(visit.getPet().getPetOwner()).isSameAs(somePetOwner); // <.>
+ assertThat(visit.getVisitAt()).isEqualTo(visitAt); // <6>
}
@Inject FakeDataService fakeDataService;
@@ -142,7 +154,7 @@ public class Pet_bookVisit_IntegTest extends VisitsModuleIntegTestAbstract {
}
----
<.> uses same fixture script used for prototyping to set up ``Pet``s and their ``PetOwner``s.
-<.> uses the xref:refguide:testing:index/fakedata/applib/services/FakeDataService.adoc[FakeDataService] to select a `Pet` persona at random and uses that person to look up the corresponding domain object.
+<.> uses the xref:refguide:testing:index/fakedata/applib/services/FakeDataService.adoc[FakeDataService] to select a random `PetOwner` and corresponding `Pet`
<.> sets up some randomised but valid argument values
<.> invokes the action, using the xref:refguide:applib:index/services/wrapper/WrapperFactory.adoc[WrapperFactory] to simulate the UI
<.> asserts that one new `Visit` has been created for the `Pet`.
@@ -150,58 +162,29 @@ public class Pet_bookVisit_IntegTest extends VisitsModuleIntegTestAbstract {
+
Run the test and check that it passes.
-* write an error scenario which checks that a reason has been provided:
-+
-[source,java]
-.Pet_bookVisit_IntegTest.java
-----
-@Test
-public void reason_is_required() {
-
- // given
- Pet somePet = fakeDataService.enums().anyOf(Pet_persona.class)
- .findUsing(serviceRegistry);
- List<Visit> before = visitRepository.findByPetOrderByVisitAtDesc(somePet);
-
- // when, then
- LocalDateTime visitAt = clockService.getClock().nowAsLocalDateTime()
- .plusDays(fakeDataService.ints().between(1, 3));
-
- assertThatThrownBy(() ->
- wrapMixin(Pet_bookVisit.class, somePet).act(visitAt, null)
- )
- .isInstanceOf(InvalidException.class)
- .hasMessage("'Reason' is mandatory");
-}
-----
* write an error scenario which checks that the `visitAt` date cannot be in the past:
+
[source,java]
-.Pet_bookVisit_IntegTest.java
+.PetOwner_bookVisit_IntegTest.java
----
@Test
public void cannot_book_in_the_past() {
// given
- Pet somePet = fakeDataService.enums().anyOf(Pet_persona.class)
+ PetOwner somePetOwner = fakeDataService.enums()
+ .anyOf(PetOwner_persona.class)
.findUsing(serviceRegistry);
- List<Visit> before = visitRepository.findByPetOrderByVisitAtDesc(somePet);
+ Pet somePet = fakeDataService.collections()
+ .anyOf(somePetOwner.getPets());
// when, then
LocalDateTime visitAt = clockService.getClock().nowAsLocalDateTime();
- String reason = fakeDataService.strings().upper(40);
assertThatThrownBy(() ->
- wrapMixin(Pet_bookVisit.class, somePet).act(visitAt, reason)
+ wrapMixin(PetOwner_bookVisit.class, somePetOwner).act(somePet, visitAt)
)
.isInstanceOf(InvalidException.class)
- .hasMessage("Must be in the future");
+ .hasMessage("Must book in the future");
}
----
-
-*
-
-
-
-
(causeway) 03/03: CAUSEWAY-2873: 08-02
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
danhaywood pushed a commit to branch CAUSEWAY-2873
in repository https://gitbox.apache.org/repos/asf/causeway.git
commit 09f6f2bf2bde2f2c4ebdbbb0355c31bc3465431a
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Mon May 27 00:02:16 2024 +0100
CAUSEWAY-2873: 08-02
---
.../modules/petclinic/pages/080-view-models.adoc | 309 ++++++++-------------
1 file changed, 123 insertions(+), 186 deletions(-)
diff --git a/antora/components/tutorials/modules/petclinic/pages/080-view-models.adoc b/antora/components/tutorials/modules/petclinic/pages/080-view-models.adoc
index 9bda1d0087..4df2b2bb50 100644
--- a/antora/components/tutorials/modules/petclinic/pages/080-view-models.adoc
+++ b/antora/components/tutorials/modules/petclinic/pages/080-view-models.adoc
@@ -6,7 +6,7 @@ So far the application consists only of domain entities and domain services.
However, the framework also supports view models.
A classic use case is to provide a home page or dashboard, but they are also used to represent certain specific business processes when there isn't necessarily a domain entity required to track the state of the process itself.
-Some real-world examples include importing/exporting spreadsheets periodically (eg changes to indexation rates), or generating extracts such as a payment file or summary PDF for an quarterly invoice run.
+Some real-world examples include importing/exporting spreadsheets periodically (eg changes to indexation rates), or generating extracts such as a payment file or summary PDF for a quarterly invoice run.
@@ -28,71 +28,109 @@ mvn -pl spring-boot:run
=== Tasks
-* update `PetRepository` and `VisitRepository` to extend `JpaRepository` (rather than simply `Repository`)
+* update `VisitRepository` to list all visits after a certain time:
+
-This will provide additional finders "for free".
+[source,java]
+----
+public interface VisitRepository extends Repository<Visit, Integer> {
+
+ List<Visit> findByVisitAtAfter(LocalDateTime visitAt);
+
+ // ...
+}
+----
-* modify `HomePageViewModel` to show the current ``PetOwner``s, ``Pet``s and ``Visit``s in three separate columns:
+* modify `HomePageViewModel` to show the current ``PetOwner``s and any ``Visit``s in the future:
+
[source,java]
----
-@Named("petclinic.HomePageViewModel")
-@DomainObject( nature = Nature.VIEW_MODEL ) // <.>
-@HomePage // <.>
+@Named(SimpleModule.NAMESPACE + ".HomePageViewModel")
+@DomainObject(nature = Nature.VIEW_MODEL) // <.>
+@HomePage // <.>
@DomainObjectLayout()
public class HomePageViewModel {
- public String title() {
- return getPetOwners().size() + " owners";
- }
+ // ...
- public List<PetOwner> getPetOwners() { // <.>
- return petOwnerRepository.findAll();
- }
- public List<Pet> getPets() { // <.>
- return petRepository.findAll();
- }
- public List<Visit> getVisits() { // <.>
- return visitRepository.findAll();
+ @Collection
+ @CollectionLayout(tableDecorator = TableDecorator.DatatablesNet.class)
+ public List<Visit> getFutureVisits() { // <.>
+ LocalDateTime now = clockService.getClock().nowAsLocalDateTime();
+ return visitRepository.findByVisitAtAfter(now);
}
- @Inject PetOwnerRepository petOwnerRepository;
- @Inject PetRepository petRepository;
+ @Inject ClockService clockService;
@Inject VisitRepository visitRepository;
+
}
----
-<.> indicates that this is a view model.
+<.> indicates that this is a xref:userguide::view-models.adoc[view model].
+Causeway provides several ways of implementing view models; this is the most straightforward.
<.> exactly one view model can be annotated as the xref:refguide:applib:index/annotation/HomePage.adoc[@HomePage]
-<.> renamed derived collection, returns ``PetOwner``s.
-<.> new derived collection returning all ``Pet``s.
-<.> new derived collection returning all ``Visits``s.
+<.> new collection returning future ``Visits``s.
-* update the `HomePageViewModel.layout.xml`:
+* update the `HomePageViewModel.layout.xml`.
++
+Here it is in its entirety:
+
[source,xml]
.HomePageViewModel.layout.xml
----
-<!-- ... -->
- <bs:row>
- <bs:col span="12" unreferencedCollections="true">
- <bs:row>
- <bs:col span="4">
- <collection id="petOwners" defaultView="table"/>
- </bs:col>
- <bs:col span="4">
- <collection id="pets" defaultView="table"/>
- </bs:col>
- <bs:col span="4">
- <collection id="visits" defaultView="table"/>
- </bs:col>
- </bs:row>
- </bs:col>
- </bs:row>
-<!-- ... -->
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<bs3:grid
+ xsi:schemaLocation="https://causeway.apache.org/applib/layout/component https://causeway.apache.org/applib/layout/component/component.xsd https://causeway.apache.org/applib/layout/grid/bootstrap3 https://causeway.apache.org/applib/layout/grid/bootstrap3/bootstrap3.xsd"
+ xmlns:cpt="https://causeway.apache.org/applib/layout/component"
+ xmlns:bs3="https://causeway.apache.org/applib/layout/grid/bootstrap3"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+ <bs3:row>
+ <bs3:col span="12">
+ <bs3:row>
+ <bs3:col span="12" unreferencedActions="true">
+ <cpt:domainObject/>
+ <cpt:action id="clearHints" hidden="EVERYWHERE"/>
+ <cpt:action id="impersonate" hidden="EVERYWHERE"/>
+ <cpt:action id="impersonateWithRoles" hidden="EVERYWHERE"/>
+ <cpt:action id="stopImpersonating" hidden="EVERYWHERE"/>
+ <cpt:action id="downloadLayoutXml" hidden="EVERYWHERE"/>
+ <cpt:action id="inspectMetamodel" hidden="EVERYWHERE"/>
+ <cpt:action id="rebuildMetamodel" hidden="EVERYWHERE"/>
+ <cpt:action id="downloadMetamodelXml" hidden="EVERYWHERE"/>
+ <cpt:action id="openRestApi" hidden="EVERYWHERE"/>
+ </bs3:col>
+ </bs3:row>
+ </bs3:col>
+ <bs3:col span="6" unreferencedCollections="true">
+ <bs3:row>
+ <bs3:col span="12">
+ <cpt:collection id="petOwners" defaultView="table"/>
+ </bs3:col>
+ </bs3:row>
+ </bs3:col>
+ <bs3:col span="6">
+ <bs3:row>
+ <bs3:col span="12">
+ <cpt:collection id="futureVisits" defaultView="table"/>
+ </bs3:col>
+ </bs3:row>
+ </bs3:col>
+ </bs3:row>
+ <bs3:row>
+ <bs3:col span="0">
+ <cpt:fieldSet name="General" id="general" unreferencedProperties="true"/>
+ </bs3:col>
+ </bs3:row>
+</bs3:grid>
+----
+
+* add `columnOrder.txt` files for the new collection:
++
+[source,text]
+.HomePageViewModel#futureVisits.columnOrder.txt
+----
+pet
+visitAt
+#version
----
-
-* update or add columnOrder.txt files for the 3 collections.
-
[#exercise-8-2-add-a-convenience-action]
@@ -114,47 +152,67 @@ mvn -pl spring-boot:run
=== Tasks
-* create a bookVisit action for HomePageViewModel, as a mixin:
+* add a new finder to `VisitRepository`:
++
+[source,java]
+.VisitRepository.java
+----
+Visit findByPetAndVisitAt(Pet pet, LocalDateTime visitAt);
+----
+
+* create a `bookVisit` action for `HomePageViewModel`, as a mixin:
+
[source,java]
.HomePageViewModel_bookVisit.java
----
@Action // <.>
+@ActionLayout(associateWith = "futureVisits")
@RequiredArgsConstructor
-public class HomePageViewModel_bookVisit { // <.>
+public class HomePageViewModel_bookVisit {
final HomePageViewModel homePageViewModel;
+ @MemberSupport
public Object act(
- PetOwner petOwner, Pet pet, LocalDateTime visitAt, String reason,
- boolean showVisit) { // <.>
- Visit visit = wrapperFactory.wrapMixin(Pet_bookVisit.class, pet).act(visitAt, reason); // <.>
- return showVisit ? visit : homePageViewModel;
+ PetOwner petOwner, Pet pet, LocalDateTime visitAt,
+ boolean showVisit) { // <.>
+ wrapperFactory.wrapMixin(PetOwner_bookVisit.class, petOwner).act(pet, visitAt); // <.>
+ if (showVisit) {
+ return visitRepository.findByPetAndVisitAt(pet, visitAt);
+ }
+ return homePageViewModel;
}
+ @MemberSupport
public List<PetOwner> autoComplete0Act(final String lastName) { // <.>
- return petOwnerRepository.findByLastNameContaining(lastName);
+ return petOwnerRepository.findByNameContaining(lastName);
}
- public List<Pet> choices1Act(PetOwner petOwner) { // <.>
- if(petOwner == null) return Collections.emptyList();
- return petRepository.findByPetOwner(petOwner);
+ @MemberSupport
+ public Set<Pet> choices1Act(PetOwner petOwner) { // <.>
+ if(petOwner == null) {
+ return Collections.emptySet();
+ }
+ return petOwner.getPets();
}
+ @MemberSupport
public LocalDateTime default2Act(PetOwner petOwner, Pet pet) { // <.>
- if(pet == null) return null;
- return factoryService.mixin(Pet_bookVisit.class, pet).default0Act();
+ if(petOwner == null || pet == null) {
+ return null;
+ }
+ return factoryService.mixin(PetOwner_bookVisit.class, petOwner).default1Act();
}
+ @MemberSupport
public String validate2Act(PetOwner petOwner, Pet pet, LocalDateTime visitAt) { // <.>
- return factoryService.mixin(Pet_bookVisit.class, pet).validate0Act(visitAt);
+ return factoryService.mixin(PetOwner_bookVisit.class, petOwner).validate1Act(visitAt);
}
- @Inject PetRepository petRepository;
+ @Inject VisitRepository visitRepository;
@Inject PetOwnerRepository petOwnerRepository;
@Inject WrapperFactory wrapperFactory;
@Inject FactoryService factoryService;
}
----
<.> declares this class as a mixin action.
-<.> The action name is derived from the mixin's class ("bookVisit").
-<.> cosmetic flag to control the UI; either remain at the home page or navigate to the newly created `Visit
+<.> cosmetic flag to control the UI; either remain at the home page or navigate to the newly created `Visit`
<.> use the xref:refguide:applib:index/services/wrapper/WrapperFactory.adoc[WrapperFactory] to delegate to the original behaviour "as if" through the UI.
If additional business rules were added to that delegate, then the mistake would be detected.
<.> Uses an xref:refguide:applib-methods:prefixes.adoc#autoComplete[autoComplete] supporting method to look up matching ``PetOwner``s based upon their name.
@@ -162,135 +220,14 @@ If additional business rules were added to that delegate, then the mistake would
<.> Computes a default for the 2^nd^ parameter, once the first two are selected.
<.> surfaces (some of) the business rules of the delegate mixin.
-* update the layout file to position:
+* update the title of `HomePageViewModel`:
+
[source,xml]
.HomePageViewModel.layout.xml
----
-<!-- ... -->
- <bs:row>
- <bs:col span="12" unreferencedActions="true">
- <domainObject/>
- <action id="bookVisit"/>
- <!-- ... -->
- </bs:col>
- </bs:row>
-<!-- ... -->
-----
-
-
-
-[#exercise-8-3-using-a-view-model-as-a-projection-of-an-entity]
-== Ex 8.3: Using a view model as a projection of an entity
-
-In the home page, the ``Visit`` instances show the `Pet` but they do not show the `PetOwner`.
-One option (probably the correct one in this case) would be to extend `Visit` itself and show this derived information:
-
-[source,java]
-.Visit.java
-----
-public PetOwner getPetOwner() {
- return getPet().getOwner();
-}
-----
-
-Alternatively, if we didn't want to "pollute" the entity with this derived property, we could use a mixin:
-
-[source,java]
-.Visit_petOwner.java
-----
-@Property
-@RequiredArgsConstructor
-public class Visit_petOwner {
-
- final Visit visit;
-
- public PetOwner prop() {
- return visit.getPet().getOwner();
- }
-}
-----
-
-Even so, this would still make the "petOwner" property visible everywhere that a `Visit` is displayed.
-
-If we instead want to be more targetted and _only_ show this "petOwner" property when displayed on the HomePage, yet another option is to implement the xref:refguide:applib:index/services/tablecol/TableColumnVisibilityService.adoc[TableColumnVisibilityService] SPI.
-This provides the context for where an object is being rendered, so this could be used to suppress the collection everywhere except the home page.
-
-A final option though, which we'll use in this exercise, is to display not the entity itself but instead a view model that "wraps" the entity and supplements with the additional data required.
-
-
-=== Solution
-
-[source,bash]
-----
-git checkout tags/08-03-view-model-projecting-an-entity
-mvn clean install
-mvn -pl spring-boot:run
-----
-
-
-=== Tasks
-
-* create a JAXB style view model `VisitPlusPetOwner`, wrapping the `Visit` entity:
-+
-[source,java]
-.VisitPlusPetOwner.java
-----
-@Named("petclinic.VisitPlusPetOwner")
-@DomainObject(nature=Nature.VIEW_MODEL)
-@DomainObjectLayout(named = "Visit")
-@XmlRootElement // <.>
-@XmlType // <1>
-@XmlAccessorType(XmlAccessType.FIELD) // <1>
-@NoArgsConstructor
-public class VisitPlusPetOwner {
-
- @Property(
- projecting = Projecting.PROJECTED, // <.>
- hidden = Where.EVERYWHERE // <.>
- )
- @Getter
- private Visit visit;
-
- VisitPlusPetOwner(Visit visit) {this.visit = visit;}
-
- public Pet getPet() {return visit.getPet();} // <.>
- public String getReason() {return visit.getReason();} // <4>
- public LocalDateTime getVisitAt() {return visit.getVisitAt();} // <4>
-
- public PetOwner getPetOwner() { // <.>
- return getPet().getPetOwner();
- }
+@ObjectSupport public String title() {
+ return getPetOwners().size() + " pet owners, " +
+ getFutureVisits() + " future visits";
}
----
-<.> Boilerplate for JAXB view models
-<.> if the icon/title is clicked, then traverse to this object rather than the view model.
-(The view model is a "projection" of the underlying `Visit`).
-<.> Nevertheless, hide this property from the UI.
-<.> expose properties from the underlying `Visit` entity
-<.> add in additional derived properties, in this case the ``Pet``'s owner.
-
-* Refactor the `getVisits` collection of `HomePageViewModel` to use the new view model:
-+
-[source,java]
-.VisitPlusPetOwner.java
-----
-public List<VisitPlusPetOwner> getVisits() {
- return visitRepository.findAll()
- .stream()
- .map(VisitPlusPetOwner::new)
- .collect(Collectors.toList());
-}
-----
-
-* update the columnOrder file for this collection to display the new property:
-+
-[source,java]
-.HomePageViewModel#visits.columnOrder.txt
-----
-petOwner
-pet
-visitAt
-----
-Run the application; the `visits` collection on the home page should now show the `PetOwner` as an additional column, but otherwise behaves the same as previously.