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 <[email protected]> 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.
