jonahkh
2020-06-16 3cf2d17add33f2cb95cfa365860d84706aa110c2
code cleanup and use photos from shared red hat google account (#44)

* code cleanup and use photos from shared red hat google account

* Added photo gallery

* Added photo gallery modal to animal creation form

* error handling for failed adoption

* test modifying servlet response for error

* remove unused param

* photo selector to save photoUrl

* show selected photo in create animal form

* Enforced eof new line

Co-authored-by: Jaime Ramírez <jaime.ramirez@redhat.com>
5 files deleted
4 files added
38 files modified
562 ■■■■■ changed files
adopt-a-pup/Readme.MD 41 ●●●●● patch | view | raw | blame | history
adopt-a-pup/adoption-service/src/main/java/com/redhat/do328/adoptApup/adoptionservice/service/AdoptionService.java 19 ●●●● patch | view | raw | blame | history
adopt-a-pup/adoption-service/src/main/resources/static/application-properties.yaml 13 ●●●●● patch | view | raw | blame | history
adopt-a-pup/adoption-service/src/test/java/com/redhat/do328/adoptApup/adoptionservice/AdoptionServiceApplicationTests.java 13 ●●●●● patch | view | raw | blame | history
adopt-a-pup/animal-service/src/main/java/com/redhat/do328/adoptApup/animalservice/AnimalController.java 6 ●●●●● patch | view | raw | blame | history
adopt-a-pup/animal-service/src/main/java/com/redhat/do328/adoptApup/animalservice/AnimalServiceApplication.java 27 ●●●●● patch | view | raw | blame | history
adopt-a-pup/animal-service/src/main/java/com/redhat/do328/adoptApup/animalservice/service/AnimalService.java 16 ●●●●● patch | view | raw | blame | history
adopt-a-pup/create-all.sh 28 ●●●●● patch | view | raw | blame | history
adopt-a-pup/mongo-data/animals.mongo 10 ●●●● patch | view | raw | blame | history
adopt-a-pup/notification-service/src/main/java/com/redhat/do328/adoptApup/notificationservice/NotificationServiceApplication.java 18 ●●●●● patch | view | raw | blame | history
adopt-a-pup/notification-service/src/main/java/com/redhat/do328/adoptApup/notificationservice/services/EmailManagerService.java 6 ●●●● patch | view | raw | blame | history
adopt-a-pup/notification-service/src/test/java/com/redhat/do328/adoptApup/notificationservice/NotificationServiceApplicationTests.java 13 ●●●●● patch | view | raw | blame | history
adopt-a-pup/shelter-service/src/main/java/com/redhat/do328/adoptApup/shelterservice/ShelterController.java 1 ●●●● patch | view | raw | blame | history
adopt-a-pup/shelter-service/src/test/java/com/redhat/do328/adoptApup/shelterservice/ShelterServiceApplicationTests.java 13 ●●●●● patch | view | raw | blame | history
adopt-a-pup/web-app/.eslintrc.json 4 ●●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/App.tsx 4 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Components/AdoptableAnimalList.tsx 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Components/AdoptionForm.test.tsx 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Components/AnimalCreateForm.test.tsx 16 ●●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Components/AnimalCreateForm.tsx 142 ●●●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Components/BullseyeSpinner.tsx 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Components/LoadingData.tsx 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Components/NotificationRequestForm.tsx 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Components/PhotoGallery.test.tsx 40 ●●●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Components/PhotoGallery.tsx 31 ●●●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Components/ShelterCreateForm.test.tsx 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Models/AnimalNotificationRequest.ts 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Models/ApproximateSize.ts 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Models/Shelter.ts 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Services/AdoptionFakeService.ts 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Services/AdoptionRESTService.test.ts 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Services/AnimalFakeService.ts 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Services/AnimalRESTService.ts 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Services/Delayer.ts 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Services/NewsFakeService.ts 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Services/NewsRESTService.ts 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Services/PhotoService.ts 5 ●●●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Services/PhotoStaticListService.ts 20 ●●●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Services/ShelterFakeService.ts 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Services/ShelterRESTService.ts 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Views/AnimalCreateView.tsx 3 ●●●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Views/AnimalDetailsView.tsx 9 ●●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Views/HomeView.tsx 1 ●●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Views/NotificationsView.tsx 6 ●●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/Views/ShelterDetailsView.tsx 2 ●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/v1.css 9 ●●●●● patch | view | raw | blame | history
adopt-a-pup/web-app/src/v2.css 10 ●●●●● patch | view | raw | blame | history
adopt-a-pup/Readme.MD
@@ -74,6 +74,39 @@
    ```
   See the script [parameters](scripts/deploy-frontend.sh) to customize the deployment.
## Animal Photos
All dog photos are stored in a shared Google Photos folder. To add a new photo for the app:
1. Upload the photo to [the shared Google Photos folder](https://photos.google.com/share/AF1QipN1a1vFP53lRgGUAoHoN67SYofoFe16zgj0DbzorjfPW5GKg6iGuzPjcQBd4nzAaQ?key=bUtEM2U4SlNsVVJtNXBBSnNTV3dfTXFQa2NsV0Rn).
2. Click the `share` button and select the `copy link` option at the bottom.
3. Paste the photo's link into  [BYTENBIT](https://app.bytenbit.com/). This will extract the actual photo link from the shared image's html response. (The shareable link returns html with the photo embedded so this must be extracted somehow)
Now that you have the image, you can either 1) add it to the preloaded list of adoptable animals or 2) for the list of photos
that are available to choose from upon creating a new animal.
If you want to add a preloaded animal or modify an existing preloaded animal's image:
1. Open `mongo-data/animals.mongo`
2. a) Add a new JSON object with VALID new Animal fields (ideally, create JSON using postman or swagger to ensure validity).
      Set the `photoUrl` field to the extracted link from BYTENBIT mentioned above.
   b) Override one of the existing `photoUrl` fields with the new link.
### If using locally
3. Changes must be pushed and the Mongo pods re-created from scratch (scripts must be run to create all animals and to not overwrite any data)
   a) You can manually insert into Mongo
        ```
        oc rsh <mongo-pod>
        mongo -u developer -p developer adopt-a-pup
        db.animals.insert(<animal-json>)
         OR
        db.animals.update({animalName: "<animal-name>"}, {$set: {photoUrl: "<URL>"}})
        ```
For images that are selectable when creating a new animal, use the same process for getting the link. Then, (For now) add
the link to the `photos` array in the `renderPhotoPickerModal` function in `AnimalCreateForm.tsx`
## Development
@@ -86,4 +119,10 @@
### Backend services
TODO...
The best way to run the backend environment is to deploy the apps in a [local CRC (CodeReady Containers) environment](https://developers.redhat.com/products/codeready-containers/overview)
Otherwise, ensure that you have Mongo running locally. Change the `resources/application.properties` file in the service(s)
that will be run and change the `spring.data.mongodb.host=mongodb` property to `spring.data.mongodb.host=localhost`
Then run `mvn spring-boot:run -f <service location>`. Multiple services will have to be run on different ports. Append
`-D server.port=<newPort>` to the end of the `mvn` command to use specific port.
adopt-a-pup/adoption-service/src/main/java/com/redhat/do328/adoptApup/adoptionservice/service/AdoptionService.java
@@ -1,7 +1,15 @@
package com.redhat.do328.adoptApup.adoptionservice.service;
import com.google.common.base.Joiner;
import com.redhat.do328.adoptApup.adoptionservice.model.*;
import com.redhat.do328.adoptApup.adoptionservice.model.AdoptionApplication;
import com.redhat.do328.adoptApup.adoptionservice.model.AdoptionApplicationResponse;
import com.redhat.do328.adoptApup.adoptionservice.model.Animal;
import com.redhat.do328.adoptApup.adoptionservice.model.AnimalStatusChangeRequest;
import com.redhat.do328.adoptApup.adoptionservice.model.Email;
import com.redhat.do328.adoptApup.adoptionservice.model.EmailNotificationRequest;
import com.redhat.do328.adoptApup.adoptionservice.model.Residency;
import com.redhat.do328.adoptApup.adoptionservice.model.Shelter;
import com.redhat.do328.adoptApup.adoptionservice.model.Status;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
@@ -13,7 +21,11 @@
import org.stringtemplate.v4.ST;
import org.stringtemplate.v4.STRawGroupDir;
import java.util.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@@ -89,6 +101,9 @@
            }
            // TODO send notification to user.. do this in parallel behind the scenes
            final ResponseEntity<Shelter> shelterResponse = restTemplate.getForEntity(shelterServiceHost + "/shelters/" + animal.getShelterId() + "/getShelter", Shelter.class);
            if (!shelterResponse.getStatusCode().is2xxSuccessful() || null == shelterResponse.getBody()) {
                throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Shelter not found");
            }
            final Shelter shelter = shelterResponse.getBody();
            final String shelterEmail = shelter.getEmail();
            // TODO send email to shelter
adopt-a-pup/adoption-service/src/main/resources/static/application-properties.yaml
File was deleted
adopt-a-pup/adoption-service/src/test/java/com/redhat/do328/adoptApup/adoptionservice/AdoptionServiceApplicationTests.java
File was deleted
adopt-a-pup/animal-service/src/main/java/com/redhat/do328/adoptApup/animalservice/AnimalController.java
@@ -28,12 +28,6 @@
        return animalService.createAnimal(animal);
    }
    @RequestMapping(method = RequestMethod.POST, value = "/{shelter-id}/createBulk")
    public List<String> createAnimalBulk(@RequestBody List<Animal> animals,
                                         @PathVariable(value = "shelter-id") String shelterId) {
        return animalService.createAnimalsBulk(animals, shelterId);
    }
    @RequestMapping(method = RequestMethod.POST, value = "/{animal-id}/setAdoptionStatus")
    public void setAdoptionStatus(@RequestBody AnimalStatusChangeRequest adoptionStatus,
                                  @PathVariable("animal-id") String animalId) {
adopt-a-pup/animal-service/src/main/java/com/redhat/do328/adoptApup/animalservice/AnimalServiceApplication.java
@@ -4,13 +4,6 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
@SpringBootApplication(scanBasePackages = "com.redhat.do328.adoptApup.animalservice")
@@ -28,24 +21,4 @@
        return new RestTemplate();
    }
    @Bean
    public Docket api() {
        return new Docket(DocumentationType.SWAGGER_2)
                .select()
                .apis(RequestHandlerSelectors
                        .basePackage("com.redhat.do328.adoptApup.animalservice"))
                .paths(PathSelectors.any())
                .build().apiInfo(apiEndPointsInfo());
    }
    private ApiInfo apiEndPointsInfo() {
        return new ApiInfoBuilder().title("GLO REST API")
                .description("GLO API documentation")
                .contact(new Contact("Stephen Hays", "https://www.spathesystems.com/", "stephen.hays@spathesystems.com"))
                .license("Apache 2.0")
                .licenseUrl("http://www.apache.org/licenses/LICENSE-2.0.html")
                .version("1.0.0")
                .build();
    }
}
adopt-a-pup/animal-service/src/main/java/com/redhat/do328/adoptApup/animalservice/service/AnimalService.java
@@ -18,7 +18,6 @@
import org.stringtemplate.v4.ST;
import org.stringtemplate.v4.STRawGroupDir;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -63,11 +62,9 @@
        if (!CollectionUtils.isEmpty(matchingNotificationCriteria)) {
            final Map<String, Email> templatesToEmail = matchingNotificationCriteria.stream()
                    .collect(Collectors.toMap(AnimalNotificationRequestCriteria::getEmail, criteria -> new Email(renderTemplate(criteria, animal), NOTIFICATION_REQUEST_SUBJECT)));
            final ResponseEntity<ResponseEntity> response = restTemplate.postForEntity(notificationServiceUrl + "/notifications/sendEmails", templatesToEmail, ResponseEntity.class);
            final ResponseEntity response = restTemplate.postForEntity(notificationServiceUrl + "/notifications/sendEmails", templatesToEmail, ResponseEntity.class);
            if (HttpStatus.OK.equals(response.getStatusCode())) {
                animalNotificationSubscriptionRepository.deleteAll(matchingNotificationCriteria);
            } else {
                // retry
            }
        }
@@ -81,15 +78,6 @@
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "animal not found for animal ID " + animalId);
        }
        return animal.get();
    }
    public List<String> createAnimalsBulk(List<Animal> animals, String shelterId) {
        final List<String> animalIds = new ArrayList<>();
        for (Animal animal : animals) {
            animal.setShelterId(shelterId);
            animalIds.add(createAnimal(animal));
        }
        return animalIds;
    }
    public void createNotificationSubscription(AnimalNotificationRequestCriteria criteria) {
@@ -106,8 +94,6 @@
        final ResponseEntity response = restTemplate.postForEntity(notificationServiceUrl + "/notifications/sendEmails", new EmailNotificationRequest(notificationRequest), ResponseEntity.class);
        if (HttpStatus.OK.equals(response.getStatusCode())) {
            animalNotificationSubscriptionRepository.delete(criteria);
        } else {
            // retry
        }
    }
adopt-a-pup/create-all.sh
File was deleted
adopt-a-pup/mongo-data/animals.mongo
@@ -1,5 +1,5 @@
{"_id":"e22d494c-c2be-4d32-bceb-ec675fd5540a","animalName":"Harvey","shelterId":"e038ae3c-592f-403e-9233-4b6eeab30e3c","breed":"lab","weight":50,"approximateSize":"M","adoptable":true,"residencyRequired":"HOUSE","squareFootageOfHome":700,"childSafe":false,"otherDogSafe":true,"_class":"com.redhat.do328.adoptApup.animalservice.model.Animal", "photoUrl": "https://lh3.googleusercontent.com/_uw16TpTuo7bzVvOsGK7P1msgIFg_YCN508ChbuGyrMGX_QMmNMa0SzA2YLq5UxV57mSpyQCOV9aIV5VTmwK6l3eJQya5tvVIlZNC1Squiy12lKybEADKptKeeuAK_V2p8aIvyXCrw"}
{"_id":"d52a8d58-9024-49dd-92b6-d443c6049ffe","animalName":"Gus","shelterId":"e038ae3c-592f-403e-9233-4b6eeab30e3c","breed":"terrier","weight":60,"approximateSize":"M","adoptable":true,"residencyRequired":"HOUSE","squareFootageOfHome":800,"childSafe":true,"otherDogSafe":false,"_class":"com.redhat.do328.adoptApup.animalservice.model.Animal", "photoUrl": "https://lh3.googleusercontent.com/4nyeYH5a6-jnnagJ8GIcwRfgXe0sEV0o2TVH55H2e7m-Yq0m5ZhU2eR7ninYhMgv_rAKmAXh8sFYNmiY90c21gyKiU0Y_IkTzqj4DziRkSufr9X2LrqpWde8E5Y-4m25DCzeE7CwYQ"}
{"_id":"b62977ad-fe79-4480-a550-06f717923017","animalName":"Theo","shelterId":"6a432062-96a4-4a66-b888-1ab7225e6b2c","breed":"golden doodle","weight":70,"approximateSize":"L","adoptable":true,"residencyRequired":"HOUSE","squareFootageOfHome":900,"childSafe":true,"otherDogSafe":true,"_class":"com.redhat.do328.adoptApup.animalservice.model.Animal", "photoUrl": "https://lh3.googleusercontent.com/rXO4d1wekCs76JEtwIZ_INBwOKm88Y0Vtda_u2iZlLHPfodAQp5in5svSd339zZBcTTMNnRn138b5RHYoUV-RDz9OfOeF86Wo2w9Hg5WxFhoomdr-X6v5rfK0qw2Ysu6O7FUz-AV3g"}
{"_id":"aac7ea0a-2374-4d4b-8d3a-71e4f896e751","animalName":"Winston","shelterId":"6a432062-96a4-4a66-b888-1ab7225e6b2c","breed":"french bulldog","weight":20,"approximateSize":"S","adoptable":true,"residencyRequired":"APARTMENT","squareFootageOfHome":200,"childSafe":true,"otherDogSafe":true,"_class":"com.redhat.do328.adoptApup.animalservice.model.Animal", "photoUrl": "https://lh3.googleusercontent.com/-5_aSw9L01JMnelgNQlksAumtOi0C_h6L_GfvSiMjDIML3z-DUNDhhFe1I-T_HL8ADcMz4uj0fI7gwlOtE6w0pbSLUwU2DzdNZj7n8DvUp26SakVXtDLFICmn3Zqy-HgByHJAs0jRg"}
{"_id":"a89cd4fc-16ce-4b51-8dd1-866d7d793322","animalName":"Darwin","shelterId":"e038ae3c-592f-403e-9233-4b6eeab30e3c","breed":"lab","weight":50,"approximateSize":"M","adoptable":true,"residencyRequired":"HOUSE","squareFootageOfHome":600,"childSafe":true,"otherDogSafe":true,"_class":"com.redhat.do328.adoptApup.animalservice.model.Animal"}
{"_id":"e22d494c-c2be-4d32-bceb-ec675fd5540a","animalName":"Harvey","shelterId":"e038ae3c-592f-403e-9233-4b6eeab30e3c","breed":"lab","weight":50,"approximateSize":"M","adoptable":true,"residencyRequired":"HOUSE","squareFootageOfHome":700,"childSafe":false,"otherDogSafe":true,"_class":"com.redhat.do328.adoptApup.animalservice.model.Animal", "photoUrl": "https://photos.app.goo.gl/fCgGto1UqfmH17H16"}
{"_id":"d52a8d58-9024-49dd-92b6-d443c6049ffe","animalName":"Gus","shelterId":"e038ae3c-592f-403e-9233-4b6eeab30e3c","breed":"terrier","weight":60,"approximateSize":"M","adoptable":true,"residencyRequired":"HOUSE","squareFootageOfHome":800,"childSafe":true,"otherDogSafe":false,"_class":"com.redhat.do328.adoptApup.animalservice.model.Animal", "photoUrl": "https://photos.app.goo.gl/jvxqSE2i8R8JqL3w9"}
{"_id":"b62977ad-fe79-4480-a550-06f717923017","animalName":"Theo","shelterId":"6a432062-96a4-4a66-b888-1ab7225e6b2c","breed":"golden doodle","weight":70,"approximateSize":"L","adoptable":true,"residencyRequired":"HOUSE","squareFootageOfHome":900,"childSafe":true,"otherDogSafe":true,"_class":"com.redhat.do328.adoptApup.animalservice.model.Animal", "photoUrl": "https://photos.app.goo.gl/gs6aEepXizJQvAKa7"}
{"_id":"aac7ea0a-2374-4d4b-8d3a-71e4f896e751","animalName":"Winston","shelterId":"6a432062-96a4-4a66-b888-1ab7225e6b2c","breed":"french bulldog","weight":20,"approximateSize":"S","adoptable":true,"residencyRequired":"APARTMENT","squareFootageOfHome":200,"childSafe":true,"otherDogSafe":true,"_class":"com.redhat.do328.adoptApup.animalservice.model.Animal", "photoUrl": "https://photos.app.goo.gl/E3wqg4eE3C5rBcRi7"}
{"_id":"a89cd4fc-16ce-4b51-8dd1-866d7d793322","animalName":"Darwin","shelterId":"e038ae3c-592f-403e-9233-4b6eeab30e3c","breed":"lab","weight":50,"approximateSize":"M","adoptable":true,"residencyRequired":"HOUSE","squareFootageOfHome":600,"childSafe":true,"otherDogSafe":true,"_class":"com.redhat.do328.adoptApup.animalservice.model.Animal", "photoUrl": "https://photos.app.goo.gl/pizuSFfZmAVtUBT66"}
adopt-a-pup/notification-service/src/main/java/com/redhat/do328/adoptApup/notificationservice/NotificationServiceApplication.java
@@ -9,17 +9,9 @@
import org.springframework.mail.javamail.JavaMailSenderImpl;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
import java.util.Properties;
@SpringBootApplication(scanBasePackages = "com.redhat.do328.adoptApup.notificationservice")
@EnableSwagger2
public class NotificationServiceApplication {
//    @Value("${spring.mail.username}")
//    private String smtpUsername;
//
//    @Value("${spring.mail.password}")
//    private String smtpPassword;
    @Value("${spring.mail.port}")
    private int smtpPort;
@@ -37,16 +29,6 @@
        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
        mailSender.setHost(smptHost);
        mailSender.setPort(smtpPort);
//        mailSender.setUsername(smtpUsername);
//        mailSender.setPassword(smtpPassword);
        Properties props = mailSender.getJavaMailProperties();
        props.put("mail.transport.protocol", "smtp");
//        props.put("mail.smtp.auth", "true");
//        props.put("mail.smtp.starttls.enable", "true");
        props.put("mail.debug", "true");
        return mailSender;
    }
}
adopt-a-pup/notification-service/src/main/java/com/redhat/do328/adoptApup/notificationservice/services/EmailManagerService.java
@@ -5,7 +5,6 @@
import com.redhat.do328.adoptApup.notificationservice.models.NotificationStatusResponse;
import com.redhat.do328.adoptApup.notificationservice.models.Status;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
@@ -15,15 +14,12 @@
@Service
public class EmailManagerService {
    @Value("${spring.mail.host}")
    private String smptHost;
    @Autowired
    private JavaMailSender emailSender;
    public NotificationStatusResponse sendEmails(EmailNotificationRequest emailNotificationRequest) {
        try {
            emailNotificationRequest.getMessagesByEmail().keySet().stream().forEach(email -> {
            emailNotificationRequest.getMessagesByEmail().keySet().forEach(email -> {
                final Email emailDetails = emailNotificationRequest.getMessagesByEmail().get(email);
                final SimpleMailMessage message = new SimpleMailMessage();
                message.setTo(email);
adopt-a-pup/notification-service/src/test/java/com/redhat/do328/adoptApup/notificationservice/NotificationServiceApplicationTests.java
File was deleted
adopt-a-pup/shelter-service/src/main/java/com/redhat/do328/adoptApup/shelterservice/ShelterController.java
@@ -9,7 +9,6 @@
@RestController
@RequestMapping("/shelters")
//@CrossOrigin(origins = "*", exposedHeaders = "Access-Control-Allow-Origin")
public class ShelterController {
    @Autowired
adopt-a-pup/shelter-service/src/test/java/com/redhat/do328/adoptApup/shelterservice/ShelterServiceApplicationTests.java
File was deleted
adopt-a-pup/web-app/.eslintrc.json
@@ -44,6 +44,10 @@
        "semi": [
            "error",
            "always"
        ],
        "eol-last": [
            "error",
            "always"
        ]
    },
    "settings": {
adopt-a-pup/web-app/src/App.tsx
@@ -28,6 +28,7 @@
import NewsRESTService from "./Services/NewsRESTService";
import SheltersCreateView from "./Views/SheltersCreateView";
import NotificationsView from "./Views/NotificationsView";
import { PhotoStaticListService } from "./Services/PhotoStaticListService";
// Initialize Backend Services
@@ -35,6 +36,7 @@
let adoptionService: AdoptionService;
let shelterService: ShelterService;
let newsService: NewsService;
const photoService = new PhotoStaticListService();
if (Config.ADOPTION_SERVICE_URL) {
    adoptionService = new AdoptionRESTService(Config.ADOPTION_SERVICE_URL);
@@ -103,7 +105,7 @@
                            <AnimalCreateView {...props}
                                shelterService={shelterService}
                                animalService={animalService}
                                photoService={photoService}
                            />}/>
                        <Route path={"/shelters/:shelterId"} render={(props) =>
                            <ShelterDetailsView {...props}
adopt-a-pup/web-app/src/Components/AdoptableAnimalList.tsx
@@ -53,4 +53,4 @@
            </GalleryItem>
        );
    }
}
}
adopt-a-pup/web-app/src/Components/AdoptionForm.test.tsx
@@ -106,4 +106,4 @@
    // we need to create a fake browser event to simulate a submit
    const event = { preventDefault: () => { } };
    formComponent.simulate("submit", event);
}
}
adopt-a-pup/web-app/src/Components/AnimalCreateForm.test.tsx
@@ -4,16 +4,28 @@
import AnimalFakeService from "../Services/AnimalFakeService";
import { Animal } from "../Models/Animal";
import { AnimalService } from "../Services/AnimalService";
import { ShelterService } from "../Services/ShelterService";
import { PhotoService } from "../Services/PhotoService";
import ShelterFakeService from "../Services/ShelterFakeService";
import { PhotoStaticListService } from "../Services/PhotoStaticListService";
describe("AnimalForm", () => {
    let animalService: AnimalService;
    let shelterService: ShelterService;
    let photoService: PhotoService;
    let component: ShallowWrapper;
    beforeEach(() => {
        animalService = new AnimalFakeService();
        component = shallow(<AnimalCreateForm animalService={animalService} />);
        shelterService = new ShelterFakeService();
        photoService = new PhotoStaticListService();
        component = shallow(<AnimalCreateForm
            animalService={animalService}
            shelterService={shelterService}
            photoService={photoService}
        />);
    });
    test("Changes state when name is changed", async() => {
@@ -119,4 +131,4 @@
    // we need to create a fake browser event to simulate a submit
    const event = { preventDefault: () => {} };
    formComponent.simulate("submit", event);
}
}
adopt-a-pup/web-app/src/Components/AnimalCreateForm.tsx
@@ -1,6 +1,6 @@
import React, {FormEvent} from "react";
import {Animal} from "../Models/Animal";
import {AnimalService} from "../Services/AnimalService";
import React, { FormEvent } from "react";
import { Animal } from "../Models/Animal";
import { AnimalService } from "../Services/AnimalService";
import {
    ActionGroup,
    Alert,
@@ -12,19 +12,23 @@
    FormGroup,
    FormSelect,
    FormSelectOption,
    TextInput
    TextInput,
    Modal
} from "@patternfly/react-core";
import {Residency} from "../Models/Residency";
import {ApproximateSize} from "../Models/ApproximateSize";
import { Residency } from "../Models/Residency";
import { ApproximateSize } from "../Models/ApproximateSize";
import BullseyeSpinner from "./BullseyeSpinner";
import {RESTConnectionError} from "../Services/RESTService";
import {ShelterService} from "../Services/ShelterService";
import {Shelter} from "../Models/Shelter";
import { RESTConnectionError } from "../Services/RESTService";
import { ShelterService } from "../Services/ShelterService";
import { Shelter } from "../Models/Shelter";
import LoadingData from "./LoadingData";
import PhotoGallery from "./PhotoGallery";
import { PhotoService } from "../Services/PhotoService";
type AnimalCreateViewProps = {
    animalService: AnimalService;
    shelterService: ShelterService;
    photoService: PhotoService;
}
type AnimalCreateFormState = {
@@ -36,6 +40,7 @@
        description: string
    }
    isSubmitting: boolean;
    isDogPhotoShown: boolean;
    animal: Animal
    shelters: Shelter[],
    loading: boolean,
@@ -43,7 +48,10 @@
        isActive: boolean,
        header: string,
        message: string
    }
    },
    isPhotoPickerModalOpen: boolean;
    galleryPhotoUrls: string[],
    photoUrl: string
}
export default class AnimalCreateForm
@@ -59,7 +67,9 @@
                title: "",
                description: ""
            },
            isDogPhotoShown: false,
            isSubmitting: false,
            isPhotoPickerModalOpen: false,
            loading: false,
            animal: this.getEmptyFields(),
            shelters: [],
@@ -67,17 +77,22 @@
                isActive: false,
                header: "",
                message: ""
            }
            },
            galleryPhotoUrls: [],
            photoUrl: ""
        };
    }
    // TODO refactor into common class
    public async componentDidMount() {
        this.setState({loading: true});
        this.setState({ loading: true });
        try {
            const shelters = await this.props.shelterService.getAll();
            this.setState({shelters});
            const [ shelters, galleryPhotoUrls ] = await Promise.all([
                this.props.shelterService.getAll(),
                this.props.photoService.getAllUrls()
            ]);
            this.setState({ shelters, galleryPhotoUrls });
            // Set default shelter as first option.
            //If we do not do this the form will not know which one is selected by default
            if (shelters[0].shelterId) {
@@ -88,7 +103,7 @@
                this.showConnectionError(error);
            }
        } finally {
            this.setState({loading: false});
            this.setState({ loading: false });
        }
    }
@@ -118,7 +133,7 @@
        // Immutability: instead of modifying the state,
        // we make a copy with the new value, and then
        // set the new state
        const animal = {...this.state.animal, animalName};
        const animal = { ...this.state.animal, animalName };
        this.setState({
            animal
        });
@@ -128,7 +143,7 @@
        // Immutability: instead of modifying the state,
        // we make a copy with the new value, and then
        // set the new state
        const animal = {...this.state.animal, shelterId};
        const animal = { ...this.state.animal, shelterId };
        this.setState({
            animal
        });
@@ -138,7 +153,7 @@
        // Immutability: instead of modifying the state,
        // we make a copy with the new value, and then
        // set the new state
        const animal = {...this.state.animal, breed};
        const animal = { ...this.state.animal, breed };
        this.setState({
            animal
        });
@@ -148,7 +163,7 @@
        // Immutability: instead of modifying the state,
        // we make a copy with the new value, and then
        // set the new state
        const animal = {...this.state.animal, approximateSize};
        const animal = { ...this.state.animal, approximateSize };
        this.setState({
            animal
        });
@@ -158,7 +173,7 @@
        // Immutability: instead of modifying the state,
        // we make a copy with the new value, and then
        // set the new state
        const animal = {...this.state.animal, residencyRequired};
        const animal = { ...this.state.animal, residencyRequired };
        this.setState({
            animal
        });
@@ -194,7 +209,7 @@
        // Immutability: instead of modifying the state,
        // we make a copy with the new value, and then
        // set the new state
        const animal = {...this.state.animal, childSafe};
        const animal = { ...this.state.animal, childSafe };
        this.setState({
            animal
        });
@@ -204,7 +219,7 @@
        // Immutability: instead of modifying the state,
        // we make a copy with the new value, and then
        // set the new state
        const animal = {...this.state.animal, otherDogSafe};
        const animal = { ...this.state.animal, otherDogSafe };
        this.setState({
            animal
        });
@@ -214,7 +229,7 @@
        // Immutability: instead of modifying the state,
        // we make a copy with the new value, and then
        // set the new state
        const animal = {...this.state.animal, adoptable};
        const animal = { ...this.state.animal, adoptable };
        this.setState({
            animal
        });
@@ -222,8 +237,10 @@
    private async handleFormSubmit(event: FormEvent) {
        if (this.isFormValid()) {
            this.setState({isSubmitting: true});
            this.setState({ isSubmitting: true });
            try {
                // TODO add photo url from state to animal
                await this.props.animalService.create(this.state.animal);
                // const animalId = await this.props.animalService.create(this.state.animal);
                // TODO photo input and then write file to server
@@ -234,16 +251,16 @@
            } catch (error) {
                this.showErrorAlert(error);
            } finally {
                this.setState({isSubmitting: false});
                this.setState({ isSubmitting: false });
            }
        } else {
            this.setState({showInvalidFormAlert: true});
            this.setState({ showInvalidFormAlert: true });
        }
        event.preventDefault();
    }
    private isFormValid() {
        const {animal} = this.state;
        const { animal } = this.state;
        const fieldIsEmpty = (field: string) => { return animal[field as keyof Animal] === ""; };
        const hasEmptyFields = Object
@@ -292,7 +309,11 @@
    }
    private handleCloseInvalidFormAlert() {
        this.setState({showInvalidFormAlert: false});
        this.setState({ showInvalidFormAlert: false });
    }
    private handleChoosePhotoButton() {
        this.setState({ isPhotoPickerModalOpen: true });
    }
    private getEmptyFields(): Animal {
@@ -325,27 +346,27 @@
    }
    public renderLoader() {
        return <BullseyeSpinner/>;
        return <BullseyeSpinner />;
    }
    public renderForm() {
        let state = this.state;
        const {animal, showInvalidFormAlert} = state;
        const { animal, showInvalidFormAlert } = state;
        return (
            <Form onSubmit={this.handleFormSubmit.bind(this)}>
                {this.renderCreationSuccessAlert()}
                {this.renderCreationErrorAlert()}
                {showInvalidFormAlert &&
                <Alert
                    id="myalert"
                    className="popup"
                    variant="danger"
                    title="Invalid form"
                    action={<AlertActionCloseButton
                        onClose={this.handleCloseInvalidFormAlert.bind(this)}
                    />}>
                    Please complete required fields
                </Alert>}
                    <Alert
                        id="myalert"
                        className="popup"
                        variant="danger"
                        title="Invalid form"
                        action={<AlertActionCloseButton
                            onClose={this.handleCloseInvalidFormAlert.bind(this)}
                        />}>
                        Please complete required fields
                    </Alert>}
                <FormGroup
                    label="Name"
                    isRequired
@@ -417,7 +438,7 @@
                        name="animal-form-adoptable"
                        aria-label="Adoptable?"
                        isChecked={animal.adoptable}
                        onChange={this.handleAdoptableChange.bind(this)}/>
                        onChange={this.handleAdoptableChange.bind(this)} />
                </FormGroup>
                <FormGroup
                    label="Residency"
@@ -494,7 +515,7 @@
                        name="animal-form-kid-safe"
                        aria-label="Safe with Kids?"
                        isChecked={animal.childSafe}
                        onChange={this.handleChildSafeChange.bind(this)}/>
                        onChange={this.handleChildSafeChange.bind(this)} />
                </FormGroup>
                <FormGroup
                    label="Safe with other animals"
@@ -508,8 +529,18 @@
                        name="animal-form-animal-safe"
                        aria-label="Safe with other Animals?"
                        isChecked={animal.otherDogSafe}
                        onChange={this.handleOtherDogSafeChange.bind(this)}/>
                        onChange={this.handleOtherDogSafeChange.bind(this)} />
                </FormGroup>
                <img src={animal.photoUrl} hidden={!this.state.isDogPhotoShown} alt={animal.photoUrl}/>
                <ActionGroup>
                    <Button
                        variant="secondary"
                        onClick={this.handleChoosePhotoButton.bind(this)}>
                        Choose Photo
                    </Button>
                    {this.renderPhotoPickerModal()}
                </ActionGroup>
                <ActionGroup>
                    <Button variant="primary" type={ButtonType.submit}>Create Animal</Button>
                </ActionGroup>
@@ -541,4 +572,27 @@
        }
        return null;
    }
}
    private renderPhotoPickerModal() {
        return (
            <Modal
                title="Select a photo"
                isOpen={this.state.isPhotoPickerModalOpen}
                onClose={() => { this.setState({ isPhotoPickerModalOpen: false }); }}
            >
                <PhotoGallery
                    photos={this.state.galleryPhotoUrls}
                    onPhotoSelect={(photoUrl) => {
                        const animal = { ...this.state.animal, photoUrl };
                        this.setState({
                            animal,
                            isPhotoPickerModalOpen: false,
                            isDogPhotoShown: true
                        });
                    }}
                />
            </Modal>
        );
    }
}
adopt-a-pup/web-app/src/Components/BullseyeSpinner.tsx
@@ -7,4 +7,4 @@
            <Spinner />
        </Bullseye>
    );
}
}
adopt-a-pup/web-app/src/Components/LoadingData.tsx
@@ -56,4 +56,4 @@
    private renderSpinner(): React.ReactNode {
        return this.props.showLoader && <BullseyeSpinner />;
    }
}
}
adopt-a-pup/web-app/src/Components/NotificationRequestForm.tsx
@@ -380,4 +380,4 @@
        }
        return null;
    }
}
}
adopt-a-pup/web-app/src/Components/PhotoGallery.test.tsx
New file
@@ -0,0 +1,40 @@
import React from "react";
import { shallow, ShallowWrapper } from "enzyme";
import PhotoGallery from "./PhotoGallery";
describe("PhotoGallery", () => {
    let component: ShallowWrapper;
    let photos: string[];
    beforeEach(() => {
        photos = ["photo1.png", "photo2.png"];
        component = shallow(<PhotoGallery photos={photos} selectedPhotoIndex={0} onPhotoSelect={() => {}} />);
    });
    test("Renders photos", () => {
        const imgs = component.find("img");
        expect(imgs).toHaveLength(2);
        expect(imgs.first().prop("src")).toBe("photo1.png");
        expect(imgs.last().prop("src")).toBe("photo2.png");
    });
    test("Renders selected photo", () => {
        component = shallow(<PhotoGallery photos={photos} selectedPhotoIndex={1} onPhotoSelect={() => {}} />);
        expect(component.find("CardBody").last().hasClass("selected")).toBe(true);
    });
    test("Calls onPhotoSelected when photo card is clicked", () => {
        const onPhotoSelected = jest.fn();
        component = shallow(<PhotoGallery photos={photos} selectedPhotoIndex={1} onPhotoSelect={onPhotoSelected} />);
        component.find("Card").first().simulate("click");
        expect(onPhotoSelected).toHaveBeenCalledWith("photo1.png", 0);
    });
});
adopt-a-pup/web-app/src/Components/PhotoGallery.tsx
New file
@@ -0,0 +1,31 @@
import React, { Component } from "react";
import { Gallery, GalleryItem, Card, CardBody } from "@patternfly/react-core";
interface PhotoGalleryProps {
    photos: string[],
    selectedPhotoIndex?: number,
    onPhotoSelect: (url: string, index: number) => void
}
export default class PhotoGallery extends Component<PhotoGalleryProps> {
    public render() {
        return (
            <Gallery>
                {this.props.photos.map(this.renderPhoto.bind(this))}
            </Gallery>
        );
    }
    private renderPhoto(url: string, index: number) {
        const className = this.props.selectedPhotoIndex === index ? "selected" : "";
        return <GalleryItem key={index}>
            <Card onClick={() => this.props.onPhotoSelect(url, index)}>
                <CardBody className={`clickable ${className}`}>
                    <img src={url} alt={`Example ${index}`} />
                </CardBody>
            </Card>
        </GalleryItem>;
    }
}
adopt-a-pup/web-app/src/Components/ShelterCreateForm.test.tsx
@@ -115,4 +115,4 @@
    // we need to create a fake browser event to simulate a submit
    const event = { preventDefault: () => {} };
    formComponent.simulate("submit", event);
}
}
adopt-a-pup/web-app/src/Models/AnimalNotificationRequest.ts
@@ -5,4 +5,4 @@
    minWeight: number,
    maxWeight: number,
    approximateSize: string
}
}
adopt-a-pup/web-app/src/Models/ApproximateSize.ts
@@ -2,4 +2,4 @@
    S = "S",
    M = "M",
    L = "L"
}
}
adopt-a-pup/web-app/src/Models/Shelter.ts
@@ -6,4 +6,4 @@
    address: string;
    email: string;
    phoneNumber: string;
}
}
adopt-a-pup/web-app/src/Services/AdoptionFakeService.ts
@@ -28,4 +28,4 @@
        console.log(`Adoption application sent for animal ${adoption.animalId}`);
    }
}
}
adopt-a-pup/web-app/src/Services/AdoptionRESTService.test.ts
@@ -77,4 +77,4 @@
            expect(error.message).toContain("There was a problem with your application");
        }
    });
});
});
adopt-a-pup/web-app/src/Services/AnimalFakeService.ts
@@ -93,4 +93,4 @@
        }));
    }
}
}
adopt-a-pup/web-app/src/Services/AnimalRESTService.ts
@@ -33,4 +33,4 @@
    public async subscribeNotifications(notificationRequest: AnimalNotificationRequest) {
        await this.post("/animals/subscribe", notificationRequest);
    }
}
}
adopt-a-pup/web-app/src/Services/Delayer.ts
@@ -4,4 +4,4 @@
            resolve(callable());
        }, milliseconds);
    });
}
}
adopt-a-pup/web-app/src/Services/NewsFakeService.ts
@@ -10,4 +10,4 @@
            { id: "n2", title: "News 2", timestamp: "1970-01-01 00:00:01" }
        ];
    }
}
}
adopt-a-pup/web-app/src/Services/NewsRESTService.ts
@@ -13,4 +13,4 @@
        return this.get<News[]>("/news/puppies");
    }
}
}
adopt-a-pup/web-app/src/Services/PhotoService.ts
New file
@@ -0,0 +1,5 @@
export interface PhotoService {
    getAllUrls(): Promise<string[]>;
}
adopt-a-pup/web-app/src/Services/PhotoStaticListService.ts
New file
@@ -0,0 +1,20 @@
import { PhotoService } from "./PhotoService";
export class PhotoStaticListService implements PhotoService {
    public async getAllUrls(): Promise<string[]> {
        /* eslint-disable max-len */
        return [
            "https://lh3.googleusercontent.com/_b01QvMCYwx224VylPr7iXTRz9aVRlXB84VYqYt-KqI3cIsHFNux6bZsC-JSBitwY0jeNRUZwW7r_3CiMZN7wLf5uu00NvTcAo2iFF9pYWPir5o0E7qBoQxNoWfrgJ3xlQfWJypq-g",
            "https://lh3.googleusercontent.com/CcRD6xV9xItZsN0v2qH0q53hOzIZCI4zx-Uq4MCRAmt9WKKlrxBlQx4VxvdU_yKrTIjVYXSDs5PnCnJQZ62pOhyLby_JTnfRXn0Dmb4zuOOK_ORkD-En69xRIDolD8gjzx5aRKu3qQ",
            "https://lh3.googleusercontent.com/YekWy49XpyEY8Z4Tohfb-SMoufPb4nyyUgSIJ3WGXj7V-o7iFWy7T3OHg6rFBmIMxePjZvIzpI-KgFllT6-6WtobCr0saZ-HRfxLI0msek0D7yHA7SxOYFW_RlWBA2LR-7yDNosCQw",
            "https://lh3.googleusercontent.com/uJv2qk-fDDC_2d6uyggx48nVzk8PJU_Ie_O0PdmkH2SQaKS7xqALA9NPgnSPAnA8LafjmW-6PfyTd_crNfcNJAAL91tpCiyvCVvflHRZFQUuWgZ51Bmah3CfMytguehF6DyNBl36Hg",
            "https://lh3.googleusercontent.com/wwSnLpy7J25_sGLzi95qSynhcMbA5XjG0ytCdul_IrfLxAIm2ILimmleBiObNkLnTLch_YauNRasRUaBpAgMZFXLDWhvMqF-Uq2gbx8K8EzJKEN0N18gxbRcykijgIyUEDf37SemRw",
            "https://lh3.googleusercontent.com/mQwdYTtwPmwk7ys861BTIQzd74UDB_h6dlPrhZbjy2dRucHRL7Av5yrBIdPgz6z4G0Gp6FP5_yuB1Tmn4KRiaLBRPkIEor8aj2v8R2yB7vbneoCXpNSazTJUZRCONr8zN5qcYdWbpg",
            "https://lh3.googleusercontent.com/PRo99gRtiZXYuE-tn7bqOejVVQ0kcxSffoA2CpQbLMX-UZBdzNfBAGAh1tSiGkgXOzCClPTIdEocLqoTWj-smtwsdhznYZ1wE3ZuYJdIVcIILBz0eNtRsE5FIQe95Pk05iZT7ud6ew",
            "https://lh3.googleusercontent.com/K26Wy4MhAtI-K2EBo6Blg7eEefbQlDMvgCp4ySBRXP88t_nD5kaZOUiMo_cQhaHrY5jSZX4gulH4550rebpvUAE79u-NKQUnsUktOpJwP73wGnZ8LFxTb_X1yGE6VW8dlFH2h4ynCA"
        ];
        /* eslint-enable max-len */
    }
}
adopt-a-pup/web-app/src/Services/ShelterFakeService.ts
@@ -44,4 +44,4 @@
            }
        ]);
    }
}
}
adopt-a-pup/web-app/src/Services/ShelterRESTService.ts
@@ -21,4 +21,4 @@
        return this.get<Shelter[]>("/shelters/getAll");
    }
}
}
adopt-a-pup/web-app/src/Views/AnimalCreateView.tsx
@@ -5,11 +5,13 @@
} from "@patternfly/react-core";
import AnimalCreateForm from "../Components/AnimalCreateForm";
import {ShelterService} from "../Services/ShelterService";
import { PhotoService } from "../Services/PhotoService";
type AnimalCreateViewProps = {
    animalService: AnimalService;
    shelterService: ShelterService;
    photoService: PhotoService;
}
@@ -30,6 +32,7 @@
                            <AnimalCreateForm
                                animalService={this.props.animalService}
                                shelterService={this.props.shelterService}
                                photoService={this.props.photoService}
                            />
                        </CardBody>
                    </Card>
adopt-a-pup/web-app/src/Views/AnimalDetailsView.tsx
@@ -135,14 +135,9 @@
                                    {animal.otherDogSafe ? "Yes" : "No"}
                                </Text>
                                <Text component="p">
                                    <strong>Residency Required: </strong>
                                    <strong>House Required: </strong>
                                    {animal.residencyRequired ? "Yes" : "No"}
                                </Text>
                                <Text component="p">
                                    <strong>Residency Required: </strong>
                                    {animal.residencyRequired ? "Yes" : "No"}
                                </Text>
                                <Text component="p">
                                    <strong>Square Footage of Home: </strong>
                                    {animal.squareFootageOfHome}
@@ -179,4 +174,4 @@
    }
}
}
adopt-a-pup/web-app/src/Views/HomeView.tsx
@@ -2,7 +2,6 @@
import { PageSection, PageSectionVariants, Text, TextContent } from "@patternfly/react-core";
export default class HomeView extends React.Component {
    public render() {
adopt-a-pup/web-app/src/Views/NotificationsView.tsx
@@ -13,8 +13,8 @@
            <React.Fragment>
                <PageSection variant={PageSectionVariants.light}>
                    <TextContent>
                        <Text component="h1">Shelters</Text>
                        <Text component="p">Our shelters</Text>
                        <Text component="h1">Subscribe to Email Notifications</Text>
                        <Text component="h3">Setup notifications for animals that meet your criteria</Text>
                    </TextContent>
                </PageSection>
                <PageSection>
@@ -23,4 +23,4 @@
            </React.Fragment>
        );
    }
}
}
adopt-a-pup/web-app/src/Views/ShelterDetailsView.tsx
@@ -103,4 +103,4 @@
        );
    }
}
}
adopt-a-pup/web-app/src/v1.css
@@ -18,3 +18,12 @@
    animation-name: popupFadeIn;
    animation-duration: .3s;
}
.selected {
    background-color: #ddffdd;
}
.clickable {
    cursor: pointer;
}
adopt-a-pup/web-app/src/v2.css
@@ -27,4 +27,12 @@
.pf-c-nav__list .pf-m-current.pf-c-nav__link::after, .pf-c-nav__list .pf-m-current > .pf-c-nav__link::after {
    background-color: #f3f3f3;
}
}
.selected {
    background-color: #ddffdd;
}
.clickable {
    cursor: pointer;
}