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
| | |
| | | ``` |
| | | 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 |
| | | |
| | |
| | | |
| | | ### 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. |
| | |
| | | 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; |
| | |
| | | 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 |
| | |
| | | } |
| | | // 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 |
| | |
| | | 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) { |
| | |
| | | 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") |
| | |
| | | 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(); |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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 |
| | | } |
| | | |
| | | } |
| | |
| | | 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) { |
| | |
| | | final ResponseEntity response = restTemplate.postForEntity(notificationServiceUrl + "/notifications/sendEmails", new EmailNotificationRequest(notificationRequest), ResponseEntity.class); |
| | | if (HttpStatus.OK.equals(response.getStatusCode())) { |
| | | animalNotificationSubscriptionRepository.delete(criteria); |
| | | } else { |
| | | // retry |
| | | } |
| | | } |
| | | |
| | |
| | | {"_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"} |
| | |
| | | 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; |
| | |
| | | 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; |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | @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); |
| | |
| | | |
| | | @RestController |
| | | @RequestMapping("/shelters") |
| | | //@CrossOrigin(origins = "*", exposedHeaders = "Access-Control-Allow-Origin") |
| | | public class ShelterController { |
| | | |
| | | @Autowired |
| | |
| | | "semi": [ |
| | | "error", |
| | | "always" |
| | | ], |
| | | "eol-last": [ |
| | | "error", |
| | | "always" |
| | | ] |
| | | }, |
| | | "settings": { |
| | |
| | | import NewsRESTService from "./Services/NewsRESTService"; |
| | | import SheltersCreateView from "./Views/SheltersCreateView"; |
| | | import NotificationsView from "./Views/NotificationsView"; |
| | | import { PhotoStaticListService } from "./Services/PhotoStaticListService"; |
| | | |
| | | |
| | | // Initialize Backend Services |
| | |
| | | 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); |
| | |
| | | <AnimalCreateView {...props} |
| | | shelterService={shelterService} |
| | | animalService={animalService} |
| | | |
| | | photoService={photoService} |
| | | />}/> |
| | | <Route path={"/shelters/:shelterId"} render={(props) => |
| | | <ShelterDetailsView {...props} |
| | |
| | | // we need to create a fake browser event to simulate a submit |
| | | const event = { preventDefault: () => { } }; |
| | | formComponent.simulate("submit", event); |
| | | } |
| | | } |
| | |
| | | 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() => { |
| | |
| | | // we need to create a fake browser event to simulate a submit |
| | | const event = { preventDefault: () => {} }; |
| | | formComponent.simulate("submit", event); |
| | | } |
| | | } |
| | |
| | | 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, |
| | |
| | | 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 = { |
| | |
| | | description: string |
| | | } |
| | | isSubmitting: boolean; |
| | | isDogPhotoShown: boolean; |
| | | animal: Animal |
| | | shelters: Shelter[], |
| | | loading: boolean, |
| | |
| | | isActive: boolean, |
| | | header: string, |
| | | message: string |
| | | } |
| | | }, |
| | | isPhotoPickerModalOpen: boolean; |
| | | galleryPhotoUrls: string[], |
| | | photoUrl: string |
| | | } |
| | | |
| | | export default class AnimalCreateForm |
| | |
| | | title: "", |
| | | description: "" |
| | | }, |
| | | isDogPhotoShown: false, |
| | | isSubmitting: false, |
| | | isPhotoPickerModalOpen: false, |
| | | loading: false, |
| | | animal: this.getEmptyFields(), |
| | | shelters: [], |
| | |
| | | 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) { |
| | |
| | | this.showConnectionError(error); |
| | | } |
| | | } finally { |
| | | this.setState({loading: false}); |
| | | this.setState({ loading: false }); |
| | | } |
| | | } |
| | | |
| | |
| | | // 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 |
| | | }); |
| | |
| | | // 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 |
| | | }); |
| | |
| | | // 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 |
| | | }); |
| | |
| | | // 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 |
| | | }); |
| | |
| | | // 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 |
| | | }); |
| | |
| | | // 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 |
| | | }); |
| | |
| | | // 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 |
| | | }); |
| | |
| | | // 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 |
| | | }); |
| | |
| | | |
| | | 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 |
| | |
| | | } 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 |
| | |
| | | } |
| | | |
| | | private handleCloseInvalidFormAlert() { |
| | | this.setState({showInvalidFormAlert: false}); |
| | | this.setState({ showInvalidFormAlert: false }); |
| | | } |
| | | |
| | | private handleChoosePhotoButton() { |
| | | this.setState({ isPhotoPickerModalOpen: true }); |
| | | } |
| | | |
| | | private getEmptyFields(): Animal { |
| | |
| | | } |
| | | |
| | | 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 |
| | |
| | | name="animal-form-adoptable" |
| | | aria-label="Adoptable?" |
| | | isChecked={animal.adoptable} |
| | | onChange={this.handleAdoptableChange.bind(this)}/> |
| | | onChange={this.handleAdoptableChange.bind(this)} /> |
| | | </FormGroup> |
| | | <FormGroup |
| | | label="Residency" |
| | |
| | | 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" |
| | |
| | | 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> |
| | |
| | | } |
| | | 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> |
| | | ); |
| | | |
| | | } |
| | | } |
| | |
| | | <Spinner /> |
| | | </Bullseye> |
| | | ); |
| | | } |
| | | } |
| | |
| | | private renderSpinner(): React.ReactNode { |
| | | return this.props.showLoader && <BullseyeSpinner />; |
| | | } |
| | | } |
| | | } |
New file |
| | |
| | | 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); |
| | | }); |
| | | |
| | | }); |
New file |
| | |
| | | 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>; |
| | | } |
| | | } |
| | |
| | | // we need to create a fake browser event to simulate a submit |
| | | const event = { preventDefault: () => {} }; |
| | | formComponent.simulate("submit", event); |
| | | } |
| | | } |
| | |
| | | minWeight: number, |
| | | maxWeight: number, |
| | | approximateSize: string |
| | | } |
| | | } |
| | |
| | | S = "S", |
| | | M = "M", |
| | | L = "L" |
| | | } |
| | | } |
| | |
| | | address: string; |
| | | email: string; |
| | | phoneNumber: string; |
| | | } |
| | | } |
| | |
| | | console.log(`Adoption application sent for animal ${adoption.animalId}`); |
| | | } |
| | | |
| | | } |
| | | } |
| | |
| | | expect(error.message).toContain("There was a problem with your application"); |
| | | } |
| | | }); |
| | | }); |
| | | }); |
| | |
| | | public async subscribeNotifications(notificationRequest: AnimalNotificationRequest) { |
| | | await this.post("/animals/subscribe", notificationRequest); |
| | | } |
| | | } |
| | | } |
| | |
| | | resolve(callable()); |
| | | }, milliseconds); |
| | | }); |
| | | } |
| | | } |
| | |
| | | { id: "n2", title: "News 2", timestamp: "1970-01-01 00:00:01" } |
| | | ]; |
| | | } |
| | | } |
| | | } |
| | |
| | | return this.get<News[]>("/news/puppies"); |
| | | } |
| | | |
| | | } |
| | | } |
New file |
| | |
| | | export interface PhotoService { |
| | | getAllUrls(): Promise<string[]>; |
| | | } |
| | | |
| | | |
New file |
| | |
| | | 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 */ |
| | | } |
| | | |
| | | } |
| | |
| | | return this.get<Shelter[]>("/shelters/getAll"); |
| | | } |
| | | |
| | | } |
| | | } |
| | |
| | | } 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; |
| | | } |
| | | |
| | | |
| | |
| | | <AnimalCreateForm |
| | | animalService={this.props.animalService} |
| | | shelterService={this.props.shelterService} |
| | | photoService={this.props.photoService} |
| | | /> |
| | | </CardBody> |
| | | </Card> |
| | |
| | | {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} |
| | |
| | | |
| | | } |
| | | |
| | | } |
| | | } |
| | |
| | | import { PageSection, PageSectionVariants, Text, TextContent } from "@patternfly/react-core"; |
| | | |
| | | |
| | | |
| | | export default class HomeView extends React.Component { |
| | | |
| | | public render() { |
| | |
| | | <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> |
| | |
| | | </React.Fragment> |
| | | ); |
| | | } |
| | | } |
| | | } |
| | |
| | | animation-name: popupFadeIn; |
| | | animation-duration: .3s; |
| | | } |
| | | |
| | | |
| | | .selected { |
| | | background-color: #ddffdd; |
| | | } |
| | | |
| | | .clickable { |
| | | cursor: pointer; |
| | | } |
| | |
| | | |
| | | .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; |
| | | } |