diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml index 0792901e..9b39d9ba 100644 --- a/.github/workflows/cd-dev.yml +++ b/.github/workflows/cd-dev.yml @@ -26,8 +26,8 @@ jobs: - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v1 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-access-key-id: ${{ secrets.AWS_S3_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_S3_SECRET_ACCESS_KEY }} aws-region: af-south-1 - name: Setup NodeJS and Cache @@ -35,6 +35,11 @@ jobs: with: node-version: 16 cache: 'npm' + + - name: Setup Enviroment File + run: | + printf "${{ secrets.ENV_FILE }}" > .env + shell: bash - name: Install Dependencies run: npm install @@ -73,7 +78,12 @@ jobs: uses: actions/setup-java@v3 with: java-version: '17' - distribution: 'temurin' + distribution: 'temurin' + + - name: Setup Enviroment File + run: | + printf "${{ secrets.ENV_FILE }}" > .env + shell: bash - name: Setup NX run: npm install nx@latest diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml index ea5b52b0..6a581695 100644 --- a/.github/workflows/cd-prod.yml +++ b/.github/workflows/cd-prod.yml @@ -26,8 +26,8 @@ jobs: - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v1 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-access-key-id: ${{ secrets.AWS_S3_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_S3_SECRET_ACCESS_KEY }} aws-region: af-south-1 - name: Setup NodeJS and Cache @@ -35,6 +35,11 @@ jobs: with: node-version: 16 cache: 'npm' + + - name: Setup Enviroment File + run: | + printf "${{ secrets.ENV_FILE }}" > .env + shell: bash - name: Install Dependencies run: npm install @@ -76,15 +81,14 @@ jobs: java-version: '17' distribution: 'temurin' - - name: Setup NX - run: npm install nx@latest - - - name: Setup Enviroment Files + - name: Setup Enviroment File run: | - printf "${{ secrets.API_APPLICATION_PROPERTIES }}" > apps/api/src/main/resources/application.properties - cat apps/api/src/main/resources/application.properties + printf "${{ secrets.ENV_FILE }}" > .env shell: bash + - name: Setup NX + run: npm install nx@latest + - name: Build API run: npm run build:api diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index 00a0e9c6..3d7d2697 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -5,6 +5,8 @@ on: branches: [ feat/*, hotfix/* ] pull_request: branches: [ dev, feat/*, hotfix/* ] + merge_group: + types: [ checks_requested ] workflow_call: permissions: @@ -56,7 +58,7 @@ jobs: files: coverage/lcov.info - name: e2e Test App - run: npm run e2e:app:ci + run: npm run e2e:app:test - name: Build App run: npm run build:app:dev diff --git a/.github/workflows/ci-prod.yml b/.github/workflows/ci-prod.yml index 48155394..d3f16776 100644 --- a/.github/workflows/ci-prod.yml +++ b/.github/workflows/ci-prod.yml @@ -13,7 +13,7 @@ jobs: name: Lint, Test & Build Wokspace runs-on: windows-latest - environment: Production + environment: Development steps: - name: Checkout Repo @@ -54,7 +54,7 @@ jobs: files: coverage/lcov.info - name: e2e Test App - run: npm run e2e:app:prod + run: npm run e2e:app:test - name: Build App run: npm run build:app:prod diff --git a/.gitignore b/.gitignore index 4199d97a..de5ca4b2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ # npm run start:app:dev +# npm run start:app:dev + # compiled output dist tmp @@ -47,3 +49,4 @@ Thumbs.db #Enviroment Files .env .prod.env +# *application.properties diff --git a/README.md b/README.md index 9450015a..0ff40355 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@
-FridgeToPlate is a user-friendly app that utilizes preference AI and relational database models to gather recipes based on the ingredients found in the user's fridge. By providing access to delicious and wholesome meal ideas, this product aims to elevate the user's cooking experience. +FridgeToPlate is a user-friendly app that utilizes preferences AI and relational database models to gather recipes based on the ingredients found in the user's fridge. By providing access to delicious and wholesome meal ideas, this product aims to elevate the user's cooking experience. COS301 Capstone Project for [Amazon Web Services](https://aws.amazon.com/). @@ -36,7 +36,7 @@ Final year Computer Science student at the University of Pretoria. I'm an enthus [![LinkedIn](https://img.shields.io/badge/LinkedIn-0077B5?style=for-the-badge&logo=linkedin&logoColor=white)](https://www.linkedin.com/in/ryan-trickett/) ### Paul Pilane -I am a Final-Year Computer Science student at the University of Pretoria. I am a young, exquisitive individual who yearns and seeks knowledge through consistent, tireless efforts to equip myself for the changing enviroments and have a strong passion for technology and innovation, and aim to solve a class of problems through the lenses of technological innovation. +I am a Final-Year Computer Science student at the University of Pretoria. I am a young, exquisitive individual who yearns and seeks knowledge through consistent, tireless efforts to equip myself for the changing environments and have a strong passion for technology and innovation, and aim to solve a class of problems through the lenses of technological innovation. [![GitHub](https://img.shields.io/badge/GitHub-100000?style=for-the-badge&logo=github&logoColor=white)](https://github.com/PaulPilane) [![LinkedIn](https://img.shields.io/badge/LinkedIn-0077B5?style=for-the-badge&logo=linkedin&logoColor=white)](https://www.linkedin.com/in/paul-pilane/) diff --git a/apps/api/src/main/java/com/fridgetoplate/api/ApiApplication.java b/apps/api/src/main/java/com/fridgetoplate/api/ApiApplication.java index 8420e41a..b3dc0d6a 100644 --- a/apps/api/src/main/java/com/fridgetoplate/api/ApiApplication.java +++ b/apps/api/src/main/java/com/fridgetoplate/api/ApiApplication.java @@ -2,7 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; +import org.springframework.web.client.RestTemplate; @SpringBootApplication @ComponentScan("com.fridgetoplate") @@ -12,4 +15,9 @@ public static void main(String[] args) { SpringApplication.run(ApiApplication.class, args); } + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder.build(); + } + } diff --git a/apps/api/src/main/java/com/fridgetoplate/api/IngredientController.java b/apps/api/src/main/java/com/fridgetoplate/api/IngredientController.java deleted file mode 100644 index 98866965..00000000 --- a/apps/api/src/main/java/com/fridgetoplate/api/IngredientController.java +++ /dev/null @@ -1,65 +0,0 @@ -/** - * This is a Java class that defines the REST API endpoints for managing ingredients in a recipe - * application. - */ -package com.fridgetoplate.api; - -import java.util.List; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.CrossOrigin; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.bind.annotation.RestController; - -import com.fridgetoplate.model.Ingredient; -import com.fridgetoplate.repository.IngredientRepository; - -@RestController -@CrossOrigin(origins = "*", allowedHeaders = "*", methods = { RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE }) -@RequestMapping("/ingredients") -public class IngredientController { - @Autowired - private IngredientRepository ingredientRepository; - - @PostMapping("/create") - public Ingredient save(@RequestBody Ingredient ingredient){ - return ingredientRepository.save(ingredient); - } - @PostMapping("/create-multi") - public Ingredient[] save(@RequestBody Ingredient[] ingredients){ - return ingredientRepository.saveAll(ingredients); - } - - @GetMapping("/{id}") - public Ingredient findById(@PathVariable(value = "id") String id){ - return ingredientRepository.findById(id); - } - - @GetMapping - public List findAll(){ - return ingredientRepository.findAll(); - } - - @GetMapping("/testing") - public String testing() { - return "Testing purposes"; - } - - @PutMapping("/{id}") - public Ingredient update(@PathVariable(value = "id") String id, @RequestBody Ingredient ingredient){ - return ingredientRepository.update(id, ingredient); - } - - - @DeleteMapping("/{id}") - public String delete(@PathVariable(value = "id") String id){ - return ingredientRepository.delete(id); - } -} diff --git a/apps/api/src/main/java/com/fridgetoplate/api/RecipeController.java b/apps/api/src/main/java/com/fridgetoplate/api/RecipeController.java deleted file mode 100644 index 55b3b49f..00000000 --- a/apps/api/src/main/java/com/fridgetoplate/api/RecipeController.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.fridgetoplate.api; - -import java.util.List; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.*; -import com.fridgetoplate.repository.RecipeRepository; -import com.fridgetoplate.model.Recipe; - -@RestController -@CrossOrigin(origins = "*", allowedHeaders = "*", methods = { RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE }) -@RequestMapping("/recipes") -public class RecipeController { - - @Autowired - private RecipeRepository recipeRepository; - - @PostMapping("/create") - public Recipe save(@RequestBody Recipe recipe){ - return recipeRepository.save(recipe); - } - - - @GetMapping("/{id}") - public Recipe findById(@PathVariable(value = "id") String id){ - return recipeRepository.findById(id); - } - - @GetMapping - public List findAll(){ - return recipeRepository.findAll(); - } - - @PutMapping("/{id}") - public Recipe update(@PathVariable(value = "id") String id, @RequestBody Recipe recipe){ - return recipeRepository.update(id, recipe); - } - - - @DeleteMapping("/{id}") - public String delete(@PathVariable(value = "id") String id){ - return recipeRepository.delete(id); - } -} diff --git a/apps/api/src/main/java/com/fridgetoplate/api/RecommendController.java b/apps/api/src/main/java/com/fridgetoplate/api/RecommendController.java deleted file mode 100644 index 83f143a9..00000000 --- a/apps/api/src/main/java/com/fridgetoplate/api/RecommendController.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.fridgetoplate.api; - -import java.util.List; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.*; -import com.fridgetoplate.repository.RecipeRepository; -import com.fridgetoplate.model.Recipe; - -@RestController -@CrossOrigin(origins = "*", allowedHeaders = "*", methods = { RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, - RequestMethod.DELETE }) -@RequestMapping("/recommend") - -public class RecommendController { - @Autowired - private RecipeRepository recipeRepository; - - @GetMapping - public List findAll() { - return recipeRepository.findAll(); - } -} \ No newline at end of file diff --git a/apps/api/src/main/java/com/fridgetoplate/controller/ExploreController.java b/apps/api/src/main/java/com/fridgetoplate/controller/ExploreController.java new file mode 100644 index 00000000..e6054783 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/controller/ExploreController.java @@ -0,0 +1,51 @@ +/** + * This is a Java class that defines the REST API endpoints for managing ingredients in a recipe + * application. + */ +package com.fridgetoplate.controller; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +import com.fridgetoplate.frontendmodels.RecipeFrontendModel; +import com.fridgetoplate.interfaces.Explore; +import com.fridgetoplate.interfaces.Recipe; +import com.fridgetoplate.model.Ingredient; +import com.fridgetoplate.model.MealPlanModel; +import com.fridgetoplate.model.ProfileModel; +import com.fridgetoplate.repository.ExploreRepository; +// import com.fridgetoplate.repository.IngredientRepository; +import com.fridgetoplate.repository.MealPlanRepository; + +@RestController +@CrossOrigin(origins = "*", allowedHeaders = "*", methods = { RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE }) +@RequestMapping("/explore") + +public class ExploreController { + + @Autowired + private ExploreRepository exploreRepository; + + + @GetMapping + public List findAll() { + return exploreRepository.findAll(); + } + + @PostMapping("/search") + public List findBySearch(@RequestBody Explore search) { + return exploreRepository.findBySearch(search); + } + +} diff --git a/apps/api/src/main/java/com/fridgetoplate/controller/MealPlanController.java b/apps/api/src/main/java/com/fridgetoplate/controller/MealPlanController.java new file mode 100644 index 00000000..7218d877 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/controller/MealPlanController.java @@ -0,0 +1,77 @@ +package com.fridgetoplate.controller; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestMethod; + +import com.fridgetoplate.frontendmodels.MealPlanFrontendModel; +import com.fridgetoplate.model.MealPlanModel; +import com.fridgetoplate.repository.MealPlanRepository; + +@RestController +@CrossOrigin(origins = "*", allowedHeaders = "*", methods = { RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE }) + +@RequestMapping("/meal-plans") +public class MealPlanController { + + @Autowired + private MealPlanRepository mealPlanRepository; + + @PostMapping("/save") + public MealPlanFrontendModel save(@RequestBody MealPlanFrontendModel mealPlan) { + + MealPlanModel plan = new MealPlanModel(); + + + + if(mealPlan.getBreakfast() != null) { + plan.setBreakfastId(mealPlan.getBreakfast().getRecipeId()); + } + else { + plan.setBreakfastId(""); + } + if(mealPlan.getLunch() != null) { + plan.setLunchId(mealPlan.getLunch().getRecipeId()); + } + else { + plan.setLunchId(""); + } + + if(mealPlan.getDinner() != null) { + plan.setDinnerId(mealPlan.getDinner().getRecipeId()); + } + else { + plan.setDinnerId(""); + } + if(mealPlan.getSnack() != null) { + plan.setSnackId(mealPlan.getSnack().getRecipeId()); + } + else { + plan.setSnackId(""); + } + + plan.setUsername(mealPlan.getUsername()); + plan.setDate(mealPlan.getDate()); + return mealPlanRepository.save(plan); + } + + @GetMapping + public List findAll() { + return mealPlanRepository.findAll(); + } + + @GetMapping("/{username}") + public MealPlanModel findByUsername(@PathVariable(value = "username") String username) { + return mealPlanRepository.findByUsername(username); + } + +} \ No newline at end of file diff --git a/apps/api/src/main/java/com/fridgetoplate/controller/NotificationController.java b/apps/api/src/main/java/com/fridgetoplate/controller/NotificationController.java new file mode 100644 index 00000000..bef44c02 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/controller/NotificationController.java @@ -0,0 +1,47 @@ +package com.fridgetoplate.controller; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +import com.fridgetoplate.frontendmodels.NotificationsResponseModel; +import com.fridgetoplate.repository.NotificationsRepository; + +@RestController +@CrossOrigin(origins = "*", allowedHeaders = "*", methods = { RequestMethod.GET, RequestMethod.DELETE }) +@RequestMapping("/notifications") + +public class NotificationController { + + @Autowired + private NotificationsRepository notificationsRepository; + + @GetMapping("/{userId}") + public NotificationsResponseModel findAll(@PathVariable(value = "userId") String userId){ + return notificationsRepository.findAll(userId); + } + + @DeleteMapping("/{notificationId}") + public String delete(@PathVariable(value = "notificationId") String notificationId){ + return notificationsRepository.delete(notificationId); + } + + @DeleteMapping("/clear/{userId}") + public String clearNotifications(@PathVariable(value = "userId") String userId){ + return notificationsRepository.clearNotifications(userId); + } + + @DeleteMapping("/clear/{userId}/{notificationType}") + public String clearAllNotificationsOfType(@PathVariable(value = "userId") String userId, @PathVariable(value = "notificationType") String type){ + return notificationsRepository.clearAllNotificationOfType(userId, type); + } + + +} diff --git a/apps/api/src/main/java/com/fridgetoplate/api/PreferenceController.java b/apps/api/src/main/java/com/fridgetoplate/controller/PreferencesController.java similarity index 52% rename from apps/api/src/main/java/com/fridgetoplate/api/PreferenceController.java rename to apps/api/src/main/java/com/fridgetoplate/controller/PreferencesController.java index 60eb7756..35aa1e9c 100644 --- a/apps/api/src/main/java/com/fridgetoplate/api/PreferenceController.java +++ b/apps/api/src/main/java/com/fridgetoplate/controller/PreferencesController.java @@ -1,4 +1,4 @@ -package com.fridgetoplate.api; +package com.fridgetoplate.controller; import java.util.List; @@ -14,30 +14,30 @@ import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; -import com.fridgetoplate.model.Preference; -import com.fridgetoplate.repository.PreferenceRepository; +import com.fridgetoplate.model.Preferences; +import com.fridgetoplate.repository.PreferencesRepository; @RestController @CrossOrigin(origins = "*", allowedHeaders = "*", methods = { RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE }) @RequestMapping("/preferences") -public class PreferenceController { +public class PreferencesController { @Autowired - private PreferenceRepository preferenceRepository; + private PreferencesRepository preferencesRepository; @PostMapping("/create") - public Preference save(@RequestBody Preference preference){ - return preferenceRepository.save(preference); + public Preferences save(@RequestBody Preferences preferences){ + return preferencesRepository.save(preferences); } - @GetMapping("/{id}") - public Preference findById(@PathVariable(value = "id") String id){ - return preferenceRepository.findById(id); + @GetMapping("/{username}") + public Preferences findById(@PathVariable(value = "username") String username){ + return preferencesRepository.findByName(username); } @GetMapping - public List findAll(){ - return preferenceRepository.findAll(); + public List findAll(){ + return preferencesRepository.findAll(); } @GetMapping("/testing") @@ -45,14 +45,14 @@ public String testing() { return "Testing purposes"; } - @PutMapping("/{id}") - public Preference update(@PathVariable(value = "id") String id, @RequestBody Preference preference){ - return preferenceRepository.update(id, preference); + @PutMapping("/{username}") + public Preferences update(@PathVariable(value = "username") String username, @RequestBody Preferences preferences){ + return preferencesRepository.update(username, preferences); } - @DeleteMapping("/{id}") - public String delete(@PathVariable(value = "id") String id){ - return preferenceRepository.delete(id); + @DeleteMapping("/{username}") + public String delete(@PathVariable(value = "username") String username){ + return preferencesRepository.delete(username); } } diff --git a/apps/api/src/main/java/com/fridgetoplate/api/ProfileController.java b/apps/api/src/main/java/com/fridgetoplate/controller/ProfileController.java similarity index 52% rename from apps/api/src/main/java/com/fridgetoplate/api/ProfileController.java rename to apps/api/src/main/java/com/fridgetoplate/controller/ProfileController.java index 00df1925..c4b247d9 100644 --- a/apps/api/src/main/java/com/fridgetoplate/api/ProfileController.java +++ b/apps/api/src/main/java/com/fridgetoplate/controller/ProfileController.java @@ -1,12 +1,12 @@ -package com.fridgetoplate.api; +package com.fridgetoplate.controller; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; -import com.fridgetoplate.model.Profile; import com.fridgetoplate.repository.ProfileRepository; +import com.fridgetoplate.frontendmodels.ProfileFrontendModel; @RestController @CrossOrigin(origins = "*", allowedHeaders = "*", methods = { RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE }) @@ -17,29 +17,23 @@ public class ProfileController { private ProfileRepository profileRepository; @PostMapping("/create") - public Profile save(@RequestBody Profile recipe) { - return profileRepository.save(recipe); + public ProfileFrontendModel save(@RequestBody ProfileFrontendModel profile) { + return profileRepository.save(profile); } - - @GetMapping("/{id}") - public Profile findById(@PathVariable(value = "id") String id) { - return profileRepository.findById(id); + @GetMapping("/{username}") + public ProfileFrontendModel findByName(@PathVariable(value = "username") String username) { + return profileRepository.findByName(username); } @GetMapping - public List findAll() { + public List findAll() { return profileRepository.findAll(); } - @GetMapping("/testing") - public String testing() { - return "Testing purposes"; - } - - @PutMapping("/{id}") - public Profile update(@PathVariable(value = "id") String id, @RequestBody Profile profile) { - return profileRepository.update(id, profile); + @PutMapping("/{username}") + public ProfileFrontendModel update(@PathVariable(value = "username") String username, @RequestBody ProfileFrontendModel profile) { + return profileRepository.update(username, profile); } @DeleteMapping("/{id}") diff --git a/apps/api/src/main/java/com/fridgetoplate/controller/RecipeController.java b/apps/api/src/main/java/com/fridgetoplate/controller/RecipeController.java new file mode 100644 index 00000000..69cb52a4 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/controller/RecipeController.java @@ -0,0 +1,56 @@ +package com.fridgetoplate.controller; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import com.fridgetoplate.model.Ingredient; +import com.fridgetoplate.model.RecipeModel; +import com.fridgetoplate.repository.RecipeRepository; +import com.fridgetoplate.frontendmodels.RecipeFrontendModel; + +@RestController +@CrossOrigin(origins = "*", allowedHeaders = "*", methods = { RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE }) +@RequestMapping("/recipes") +public class RecipeController { + + @Autowired + private RecipeRepository recipeRepository; + + @PostMapping("/create") + public RecipeFrontendModel save(@RequestBody RecipeFrontendModel recipe){ + // Save the recipe + return recipeRepository.save(recipe); + } + + @GetMapping("/{id}") + public RecipeFrontendModel findById(@PathVariable(value = "id") String id){ + return recipeRepository.findById(id); + } + + @GetMapping("/creator/{username}") + public List findRecipesByUsername(@PathVariable(value = "username") String username){ + return recipeRepository.getRecipesByUsername(username); + } + + @GetMapping("/name/{recipename}") + public List findRecipesByRecipename(@PathVariable(value = "recipename") String recipename){ + return recipeRepository.getRecipesByRecipename(recipename); + } + + @GetMapping + public List findAll(){ + return recipeRepository.findAll(); + } + + @PutMapping("/{id}") + public RecipeModel update(@PathVariable(value = "id") String id, @RequestBody RecipeModel recipe){ + return recipeRepository.update(id, recipe); + } + + + @DeleteMapping("/{id}") + public String delete(@PathVariable(value = "id") String id){ + return recipeRepository.delete(id); + } +} diff --git a/apps/api/src/main/java/com/fridgetoplate/controller/RecommendController.java b/apps/api/src/main/java/com/fridgetoplate/controller/RecommendController.java new file mode 100644 index 00000000..7c391321 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/controller/RecommendController.java @@ -0,0 +1,114 @@ +package com.fridgetoplate.controller; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import com.fridgetoplate.frontendmodels.RecipeFrontendModel; +import com.fridgetoplate.frontendmodels.RecipePreferencesFrontendModel; +import com.fridgetoplate.frontendmodels.RecommendFrontendModel; +import com.fridgetoplate.repository.RecipeRepository; +import com.fridgetoplate.repository.RecommendRepository; +import com.fridgetoplate.service.ExternalApiService; +import com.fridgetoplate.utils.SpoonacularRecipeConverter; + +@RestController +@CrossOrigin(origins = "*", allowedHeaders = "*", methods = { RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE }) +@RequestMapping("/recommend") + +public class RecommendController { + @Autowired + private RecipeRepository recipeRepository; + + @Autowired + private RecommendRepository recommendRepository; + + @Autowired + private ExternalApiService apiService; + + @GetMapping + public List findAll() { + try{ + return recipeRepository.findAll(); + } + catch(Exception error){ + System.out.println(error); + return new ArrayList<>(); + } + } + + @PostMapping + public List getExternalRecommendation(@RequestBody RecommendFrontendModel userRecommendation) { + + try{ + if(userRecommendation.getUsername() == null || userRecommendation.getRecipePreferences() == null) + return new ArrayList(); + + //0. Store User recommendation object + recommendRepository.save(userRecommendation); + + RecipePreferencesFrontendModel recipePreferences = userRecommendation.getRecipePreferences(); + + List dbQueryResults = recipeRepository.findAllByPreferences(recipePreferences, userRecommendation.getIngredients()); + + if(dbQueryResults.size() < 25){ + SpoonacularRecipeConverter converter = new SpoonacularRecipeConverter(); + + //1. Query External API and convert to Recipe + RecipeFrontendModel[] apiQueryResults = converter.unconvert(apiService.spoonacularRecipeSearch(recipePreferences, userRecommendation.getIngredients()).getResults()); + + //2. Add External API recipes to DB + if(apiQueryResults.length != 0) + recipeRepository.saveBatch( converter.toRecipeModelArray(apiQueryResults) ); + + //.3 Query Database by prefrence + dbQueryResults = recipeRepository.findAllByPreferences(recipePreferences, userRecommendation.getIngredients()); + + //Pad results - DEMO. + dbQueryResults.addAll( Arrays.asList(apiQueryResults) ); + + } + + return dbQueryResults; + } + catch(Exception error){ + System.out.println(error); + return new ArrayList<>(); + } + + } + + @PostMapping("/create") + public RecommendFrontendModel addRecommendation(@RequestBody RecommendFrontendModel userRecommendation){ + try{ + recommendRepository.save(userRecommendation); + return userRecommendation; + } catch(Exception error){ + System.out.println(error); + return userRecommendation; + } + + } + @PutMapping("/{id}") + public RecommendFrontendModel updatePreferences(@RequestBody RecommendFrontendModel userRecommendation) { + try{ + recommendRepository.updateRecommendPreferences(userRecommendation); + return userRecommendation; + } catch( Exception error ) { + System.out.println(error); + return userRecommendation; + } + } + + @GetMapping("/{username}") + public RecommendFrontendModel getUserRecommendationPreferences(@PathVariable String username){ + try{ + return recommendRepository.getById(username); + } catch (Exception error){ + System.out.println(username); + return new RecommendFrontendModel(); + } + } +} \ No newline at end of file diff --git a/apps/api/src/main/java/com/fridgetoplate/controller/ReviewController.java b/apps/api/src/main/java/com/fridgetoplate/controller/ReviewController.java new file mode 100644 index 00000000..fc5cfe12 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/controller/ReviewController.java @@ -0,0 +1,60 @@ +package com.fridgetoplate.controller; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +import com.fridgetoplate.model.RecipeModel; +import com.fridgetoplate.model.Review; +import com.fridgetoplate.repository.ReviewRepository; + +@RestController +@CrossOrigin(origins = "*", allowedHeaders = "*", methods = { RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE }) +@RequestMapping("/reviews") + +public class ReviewController { + + @Autowired + private ReviewRepository reviewRepository; + + @PostMapping("/create") + public Review save(@RequestBody Review review){ + return reviewRepository.save(review); + } + + @GetMapping("/{id}") + public List findReviewsById(@PathVariable(value = "id") String id){ + return reviewRepository.getReviewsByRecipeId(id); + } + + @GetMapping("/{recipeId}/{reviewId}") + public Review findById(@PathVariable(value = "recipeId") String recipeId, @PathVariable(value = "reviewId") String reviewId) { + + return reviewRepository.getReviewByReviewId(recipeId, reviewId); + } + + @GetMapping("/creator/{username}") + public List findReviewsByUsername(@PathVariable(value = "username") String username){ + return reviewRepository.getReviewsByUsername(username); + } + + @GetMapping + public List findAll(){ + return reviewRepository.findAll(); + } + + @DeleteMapping("/{recipeId}/{reviewId}") + public String delete(@PathVariable(value = "recipeId") String recipeId, @PathVariable(value = "reviewId") String reviewId) { + return reviewRepository.delete(recipeId, reviewId); + } +} diff --git a/apps/api/src/main/java/com/fridgetoplate/frontendmodels/MealPlanFrontendModel.java b/apps/api/src/main/java/com/fridgetoplate/frontendmodels/MealPlanFrontendModel.java new file mode 100644 index 00000000..3a436593 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/frontendmodels/MealPlanFrontendModel.java @@ -0,0 +1,76 @@ +package com.fridgetoplate.frontendmodels; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBHashKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapperFieldModel; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBRangeKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTyped; +import com.fridgetoplate.interfaces.MealPlan; +import com.fridgetoplate.interfaces.RecipeDesc; + + +public class MealPlanFrontendModel extends MealPlan{ + + + private RecipeDesc breakfast; + + private RecipeDesc lunch; + + private RecipeDesc dinner; + + private RecipeDesc snack; + + // getters + + @DynamoDBHashKey(attributeName = "username") + public String getUsername() { + return username; + } + + @DynamoDBRangeKey(attributeName = "date") + public String getDate() { + return date; + } + + @DynamoDBAttribute(attributeName = "breakfast") + @DynamoDBTyped(DynamoDBMapperFieldModel.DynamoDBAttributeType.M) + public RecipeDesc getBreakfast() { + return breakfast; + } + + @DynamoDBAttribute(attributeName = "lunch") + @DynamoDBTyped(DynamoDBMapperFieldModel.DynamoDBAttributeType.M) + public RecipeDesc getLunch() { + return lunch; + } + + @DynamoDBAttribute(attributeName = "dinner") + @DynamoDBTyped(DynamoDBMapperFieldModel.DynamoDBAttributeType.M) + public RecipeDesc getDinner() { + return dinner; + } + + @DynamoDBAttribute(attributeName = "snack") + @DynamoDBTyped(DynamoDBMapperFieldModel.DynamoDBAttributeType.M) + public RecipeDesc getSnack() { + return snack; + } + + // Setters + public void setBreakfast(RecipeDesc breakfast) { + this.breakfast = breakfast; + } + + public void setLunch(RecipeDesc lunch) { + this.lunch = lunch; + } + + public void setDinner(RecipeDesc dinner) { + this.dinner = dinner; + } + + public void setSnack(RecipeDesc snack) { + this.snack = snack; + } + +} diff --git a/apps/api/src/main/java/com/fridgetoplate/frontendmodels/NotificationsResponseModel.java b/apps/api/src/main/java/com/fridgetoplate/frontendmodels/NotificationsResponseModel.java new file mode 100644 index 00000000..8f8cc14e --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/frontendmodels/NotificationsResponseModel.java @@ -0,0 +1,14 @@ +package com.fridgetoplate.frontendmodels; + +import java.util.List; + +import com.fridgetoplate.model.NotificationModel; + +import lombok.Data; + +@Data +public class NotificationsResponseModel { + private List general; + + private List recommendations; +} diff --git a/apps/api/src/main/java/com/fridgetoplate/frontendmodels/ProfileFrontendModel.java b/apps/api/src/main/java/com/fridgetoplate/frontendmodels/ProfileFrontendModel.java new file mode 100644 index 00000000..e3c223b7 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/frontendmodels/ProfileFrontendModel.java @@ -0,0 +1,58 @@ +package com.fridgetoplate.frontendmodels; + +import java.util.List; + +import com.fridgetoplate.interfaces.Profile; +import com.fridgetoplate.interfaces.RecipeDesc;; + +public class ProfileFrontendModel extends Profile { + + public MealPlanFrontendModel currMealPlan; + + public List savedRecipes; + + public List createdRecipes; + + public String getUsername() { + return username; + } + + public String getEmail() { + return email; + } + + public String getDisplayName() { + return displayName; + } + + public String getProfilePic() { + return profilePic; + } + + public List getSavedRecipes() { + return savedRecipes; + } + + // setters + public void setSavedRecipes(List savedRecipes) { + this.savedRecipes = savedRecipes; + } + + public List getCreatedRecipes() { + return createdRecipes; + } + + public MealPlanFrontendModel getCurrMealPlan() { + return currMealPlan; + } + + public void setCurrMealPlan(MealPlanFrontendModel currMealPlan) { + this.currMealPlan = currMealPlan; + } + + public void setCreatedRecipes(List createdRecipes) { + this.createdRecipes = createdRecipes; + } + + +} diff --git a/apps/api/src/main/java/com/fridgetoplate/frontendmodels/RecipeFrontendModel.java b/apps/api/src/main/java/com/fridgetoplate/frontendmodels/RecipeFrontendModel.java new file mode 100644 index 00000000..53719e8b --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/frontendmodels/RecipeFrontendModel.java @@ -0,0 +1,93 @@ +package com.fridgetoplate.frontendmodels; + +import java.util.List; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAutoGeneratedKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBHashKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapperFieldModel; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTyped; +import com.fridgetoplate.interfaces.Recipe; +import com.fridgetoplate.model.Ingredient; +import com.fridgetoplate.model.Review; + +@DynamoDBTable(tableName = "recipes") +public class RecipeFrontendModel extends Recipe { + + private List reviews; + + // The getters + @DynamoDBHashKey(attributeName = "recipeId") + @DynamoDBAutoGeneratedKey + public String getRecipeId() { + return recipeId; + } + + @DynamoDBAttribute(attributeName = "recipeImage") + public String getRecipeImage() { + return recipeImage; + } + + @DynamoDBAttribute(attributeName = "name") + public String getName() { + return name; + } + + @DynamoDBAttribute(attributeName = "tags") + public List getTags() { + return tags; + } + + @DynamoDBAttribute(attributeName = "meal") + public String getMeal() { + return meal; + } + + @DynamoDBAttribute(attributeName = "description") + public String getDescription() { + return description; + } + + @DynamoDBAttribute(attributeName = "ingredients") + public List getIngredients() { + return ingredients; + } + + @DynamoDBAttribute(attributeName = "steps") + public List getSteps() { + return steps; + } + + @DynamoDBAttribute(attributeName = "prepTime") + @DynamoDBTyped(DynamoDBMapperFieldModel.DynamoDBAttributeType.N) + public Integer getPrepTime() { + return prepTime; + } + + @DynamoDBAttribute(attributeName = "servings") + @DynamoDBTyped(DynamoDBMapperFieldModel.DynamoDBAttributeType.N) + public Integer getServings() { + return servings; + } + + @DynamoDBAttribute(attributeName = "difficulty") + public String getDifficulty() { + return difficulty; + } + + @DynamoDBAttribute(attributeName = "creator") + public String getCreator() { + return creator; + } + + @DynamoDBAttribute(attributeName = "reviews") + @DynamoDBTyped(DynamoDBMapperFieldModel.DynamoDBAttributeType.S) + public List getReviews(){ + return this.reviews; + } + + public void setReviews(List reviews) { + this.reviews = reviews; + } +} diff --git a/apps/api/src/main/java/com/fridgetoplate/frontendmodels/RecipePreferencesFrontendModel.java b/apps/api/src/main/java/com/fridgetoplate/frontendmodels/RecipePreferencesFrontendModel.java new file mode 100644 index 00000000..afe2b594 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/frontendmodels/RecipePreferencesFrontendModel.java @@ -0,0 +1,61 @@ +package com.fridgetoplate.frontendmodels; + +import com.fridgetoplate.model.Ingredient; + +public class RecipePreferencesFrontendModel { + + private String difficulty; + private String meal; + private String servings; + private String rating; + private String[] keywords; + private String prepTime; + + public String getDifficulty() { + return difficulty; + } + + public String getMeal() { + return meal; + } + + public String getServings() { + return servings; + } + + public String getRating() { + return rating; + } + + public String[] getKeywords() { + return keywords; + } + + public String getPrepTime() { + return prepTime; + } + + public void setDifficulty(String difficulty) { + this.difficulty = difficulty; + } + + public void setMeal(String meal) { + this.meal = meal; + } + + public void setServings(String servings) { + this.servings = servings; + } + + public void setRating(String rating) { + this.rating = rating; + } + + public void setKeywords(String[] keywords) { + this.keywords = keywords; + } + + public void setPrepTime(String prepTime) { + this.prepTime = prepTime; + } +} \ No newline at end of file diff --git a/apps/api/src/main/java/com/fridgetoplate/frontendmodels/RecommendFrontendModel.java b/apps/api/src/main/java/com/fridgetoplate/frontendmodels/RecommendFrontendModel.java new file mode 100644 index 00000000..a433c892 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/frontendmodels/RecommendFrontendModel.java @@ -0,0 +1,35 @@ +package com.fridgetoplate.frontendmodels; + +import java.util.List; + +import com.fridgetoplate.model.Ingredient; + +public class RecommendFrontendModel { + private String username; + private List ingredients; + private RecipePreferencesFrontendModel recipePreferences; + + public List getIngredients() { + return ingredients; + } + + public String getUsername() { + return username; + } + + public RecipePreferencesFrontendModel getRecipePreferences() { + return recipePreferences; + } + + public void setUsername(String username){ + this.username = username; + } + + public void setIngredients(List ingredients){ + this.ingredients = ingredients; + } + + public void setPreferences(RecipePreferencesFrontendModel preferences){ + this.recipePreferences = preferences; + } +} diff --git a/apps/api/src/main/java/com/fridgetoplate/interfaces/Difficulty.java b/apps/api/src/main/java/com/fridgetoplate/interfaces/Difficulty.java new file mode 100644 index 00000000..9c15273b --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/interfaces/Difficulty.java @@ -0,0 +1,13 @@ +package com.fridgetoplate.interfaces; + +public enum Difficulty { + Easy("Easy"), + Medium("Medium"), + Hard("Hard"); + + public final String difficulty; + + private Difficulty(String label) { + this.difficulty = label; + } +} diff --git a/apps/api/src/main/java/com/fridgetoplate/interfaces/Explore.java b/apps/api/src/main/java/com/fridgetoplate/interfaces/Explore.java new file mode 100644 index 00000000..50f16f0a --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/interfaces/Explore.java @@ -0,0 +1,35 @@ +package com.fridgetoplate.interfaces; +import java.util.List; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute; + +import lombok.Data; + +@Data +public class Explore { + + protected String search; + + protected String type; + + protected List tags; + + protected String difficulty; + + public String getSearch() { + return search; + } + + public String getType() { + return type; + } + + public String getDifficulty() { + return difficulty; + } + + public List getTags() { + return tags; + } +} + diff --git a/apps/api/src/main/java/com/fridgetoplate/interfaces/Meal.java b/apps/api/src/main/java/com/fridgetoplate/interfaces/Meal.java new file mode 100644 index 00000000..4e11aae5 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/interfaces/Meal.java @@ -0,0 +1,15 @@ +package com.fridgetoplate.interfaces; + +public enum Meal { + Breakfast("Breakfast"), + Lunch("lunch"), + Dinner("Dinner"), + Snack("Snack"), + Dessert("Dessert"); + + public final String mealType; + + private Meal(String meal) { + this.mealType = meal; + } +} diff --git a/apps/api/src/main/java/com/fridgetoplate/interfaces/MealPlan.java b/apps/api/src/main/java/com/fridgetoplate/interfaces/MealPlan.java new file mode 100644 index 00000000..9df08560 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/interfaces/MealPlan.java @@ -0,0 +1,11 @@ +package com.fridgetoplate.interfaces; + +import lombok.Data; + +@Data +public class MealPlan { + + protected String username; + + protected String date; +} diff --git a/apps/api/src/main/java/com/fridgetoplate/interfaces/Notification.java b/apps/api/src/main/java/com/fridgetoplate/interfaces/Notification.java new file mode 100644 index 00000000..fc19caa1 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/interfaces/Notification.java @@ -0,0 +1,18 @@ +package com.fridgetoplate.interfaces; + +import lombok.Data; + +@Data +public class Notification { + protected String recipeId; + + protected String notificationId; + + protected String userName; + + protected String profilePictureUrl; + + protected String comment; + + protected String notificationType; +} diff --git a/apps/api/src/main/java/com/fridgetoplate/interfaces/Profile.java b/apps/api/src/main/java/com/fridgetoplate/interfaces/Profile.java new file mode 100644 index 00000000..b32393bf --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/interfaces/Profile.java @@ -0,0 +1,16 @@ +package com.fridgetoplate.interfaces; + +import lombok.Data; + +@Data +public class Profile { + + protected String username; + + protected String email; + + protected String displayName; + + protected String profilePic; + +} \ No newline at end of file diff --git a/apps/api/src/main/java/com/fridgetoplate/interfaces/Recipe.java b/apps/api/src/main/java/com/fridgetoplate/interfaces/Recipe.java new file mode 100644 index 00000000..a07f5a00 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/interfaces/Recipe.java @@ -0,0 +1,51 @@ +package com.fridgetoplate.interfaces; + +import java.util.List; +import com.fridgetoplate.model.Ingredient; + +public class Recipe extends RecipeDesc { + + protected String description; + + protected String meal; + + protected Integer prepTime; + + protected Integer servings; + + protected List ingredients; + + protected List steps; + + protected String creator; + + // Setters + public void setDescription(String description) { + this.description = description; + } + + public void setMeal(String meal) { + this.meal = meal; + } + + public void setPrepTime(Integer prepTime) { + this.prepTime = prepTime; + } + + public void setServings(Integer servings) { + this.servings = servings; + } + + public void setIngredients(List ingredients) { + this.ingredients = ingredients; + } + + public void setSteps(List steps) { + this.steps = steps; + } + + public void setCreator(String creator) { + this.creator = creator; + } + +} diff --git a/apps/api/src/main/java/com/fridgetoplate/interfaces/RecipeDesc.java b/apps/api/src/main/java/com/fridgetoplate/interfaces/RecipeDesc.java new file mode 100644 index 00000000..b35b094f --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/interfaces/RecipeDesc.java @@ -0,0 +1,20 @@ +package com.fridgetoplate.interfaces; + +import java.util.List; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBDocument; +import lombok.Data; + +@Data +@DynamoDBDocument +public class RecipeDesc { + + protected String recipeId; + + protected String recipeImage; + + protected String name; + + protected List tags; + + protected String difficulty; +} diff --git a/apps/api/src/main/java/com/fridgetoplate/interfaces/RecipePreferences.java b/apps/api/src/main/java/com/fridgetoplate/interfaces/RecipePreferences.java new file mode 100644 index 00000000..ffcccc4a --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/interfaces/RecipePreferences.java @@ -0,0 +1,23 @@ +package com.fridgetoplate.interfaces; +import java.util.List; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBDocument; + +import lombok.Data; + +@Data +@DynamoDBDocument +public class RecipePreferences { + + private String difficulty; + + private String meal; + + private String servings; + + private String rating; + + private List keywords; + + private String prepTime; +} diff --git a/apps/api/src/main/java/com/fridgetoplate/interfaces/Recommend.java b/apps/api/src/main/java/com/fridgetoplate/interfaces/Recommend.java new file mode 100644 index 00000000..9937fb4d --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/interfaces/Recommend.java @@ -0,0 +1,19 @@ +package com.fridgetoplate.interfaces; + +import java.util.List; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBDocument; +import com.fridgetoplate.model.Ingredient; + +import lombok.Data; + +@Data +@DynamoDBDocument +public class Recommend { + + protected String username; + + protected List ingredients; + + protected RecipePreferences recipePreferences; +} diff --git a/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularAnalyzedInstruction.java b/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularAnalyzedInstruction.java new file mode 100644 index 00000000..e83cf57f --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularAnalyzedInstruction.java @@ -0,0 +1,9 @@ +package com.fridgetoplate.interfaces; + +import lombok.Data; + +@Data +public class SpoonacularAnalyzedInstruction { + private String name; + private SpoonacularStep[] steps; +} diff --git a/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularEquipment.java b/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularEquipment.java new file mode 100644 index 00000000..fd170aa1 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularEquipment.java @@ -0,0 +1,11 @@ +package com.fridgetoplate.interfaces; + +import lombok.Data; + +@Data +public class SpoonacularEquipment { + private int id; + private String name; + private String localizedName; + private String image; +} \ No newline at end of file diff --git a/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularIngredient.java b/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularIngredient.java new file mode 100644 index 00000000..7df23676 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularIngredient.java @@ -0,0 +1,11 @@ +package com.fridgetoplate.interfaces; + +import lombok.Data; + +@Data +public class SpoonacularIngredient { + private int id; + private String name; + private String localizedName; + private String image; +} \ No newline at end of file diff --git a/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularLength.java b/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularLength.java new file mode 100644 index 00000000..233c034a --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularLength.java @@ -0,0 +1,9 @@ +package com.fridgetoplate.interfaces; + +import lombok.Data; + +@Data +public class SpoonacularLength { + private int number; + private String unit; +} diff --git a/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularRecipe.java b/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularRecipe.java new file mode 100644 index 00000000..cf7c2a91 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularRecipe.java @@ -0,0 +1,40 @@ +package com.fridgetoplate.interfaces; + +import lombok.Data; + +@Data +public class SpoonacularRecipe { + private boolean vegetarian; + private boolean vegan; + private boolean glutenFree; + private boolean dairyFree; + private boolean veryHealthy; + private boolean cheap; + private boolean veryPopular; + private boolean sustainable; + private boolean lowFodmap; + private int weightWatcherSmartPoints; + private String gaps; + private int preparationMinutes; + private int cookingMinutes; + private int aggregateLikes; + private int healthScore; + private String creditsText; + private String sourceName; + private int pricePerServing; + private int id; + private String title; + private int readyInMinutes; + private int servings; + private String sourceUrl; + private String image; + private String imageType; + private String summary; + private String [] cuisines; + private String [] dishTypes; + private String [] diets; + private String [] occasions; + private SpoonacularAnalyzedInstruction[] analyzedInstructions; + private String spoonacularSourceUrl; + private String license; +} diff --git a/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularResponse.java b/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularResponse.java new file mode 100644 index 00000000..eaf3e138 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularResponse.java @@ -0,0 +1,11 @@ +package com.fridgetoplate.interfaces; + +import lombok.Data; + +@Data +public class SpoonacularResponse { + private SpoonacularRecipe[] results; + private int offset; + private int number; + private int totalResults; +} diff --git a/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularStep.java b/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularStep.java new file mode 100644 index 00000000..e4128c7c --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/interfaces/SpoonacularStep.java @@ -0,0 +1,12 @@ +package com.fridgetoplate.interfaces; + +import lombok.Data; + +@Data +public class SpoonacularStep { + private int number; + private String step; + private SpoonacularIngredient[] ingredients; + private SpoonacularEquipment[] equipment; + private SpoonacularLength length; +} diff --git a/apps/api/src/main/java/com/fridgetoplate/model/Ingredient.java b/apps/api/src/main/java/com/fridgetoplate/model/Ingredient.java index d45176bd..0fa3347f 100644 --- a/apps/api/src/main/java/com/fridgetoplate/model/Ingredient.java +++ b/apps/api/src/main/java/com/fridgetoplate/model/Ingredient.java @@ -1,19 +1,17 @@ package com.fridgetoplate.model; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAutoGeneratedKey; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBHashKey; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBDocument; import lombok.Data; @Data -@DynamoDBTable(tableName = "ingredients") +@DynamoDBDocument public class Ingredient { - @DynamoDBHashKey - @DynamoDBAutoGeneratedKey - private String ingredientId; - @DynamoDBAttribute private String name; + + private Integer amount; + + private String unit; + } diff --git a/apps/api/src/main/java/com/fridgetoplate/model/MealPlanModel.java b/apps/api/src/main/java/com/fridgetoplate/model/MealPlanModel.java new file mode 100644 index 00000000..d0256a57 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/model/MealPlanModel.java @@ -0,0 +1,71 @@ +package com.fridgetoplate.model; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBHashKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBRangeKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable; +import com.fridgetoplate.interfaces.MealPlan; + +@DynamoDBTable(tableName = "meal_plans") +public class MealPlanModel extends MealPlan { + + private String breakfastId; + + private String lunchId; + + private String dinnerId; + + private String snackId; + + // Getters + + @DynamoDBHashKey(attributeName = "username") + public String getUsername() { + return username; + } + + @DynamoDBRangeKey(attributeName = "date") + public String getDate() { + return date; + } + + @DynamoDBAttribute(attributeName = "breakfast_id") + public String getBreakfastId() { + return breakfastId; + } + + @DynamoDBAttribute(attributeName = "lunch_id") + public String getLunchId() { + return lunchId; + } + + @DynamoDBAttribute(attributeName = "dinner_id") + public String getDinnerId() { + return dinnerId; + } + + @DynamoDBAttribute(attributeName = "snack_id") + public String getSnackId() { + return snackId; + } + + // setters + @DynamoDBAttribute(attributeName = "breakfast_id") + public void setBreakfastId(String breakfastId) { + this.breakfastId = breakfastId; + } + + public void setLunchId(String lunchId) { + this.lunchId = lunchId; + } + + public void setDinnerId(String dinnerId) { + this.dinnerId = dinnerId; + } + + public void setSnackId(String snackId) { + this.snackId = snackId; + } + + +} diff --git a/apps/api/src/main/java/com/fridgetoplate/model/NotificationModel.java b/apps/api/src/main/java/com/fridgetoplate/model/NotificationModel.java new file mode 100644 index 00000000..47627930 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/model/NotificationModel.java @@ -0,0 +1,54 @@ +package com.fridgetoplate.model; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAutoGeneratedKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBHashKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable; +import com.fridgetoplate.interfaces.Notification; + +@DynamoDBTable(tableName = "notifications") +public class NotificationModel extends Notification{ + + private String userId; + + @DynamoDBHashKey(attributeName = "notificationId") + @DynamoDBAutoGeneratedKey + public String getNotificationId() { + return notificationId; + } + + @DynamoDBAttribute(attributeName = "recipeId") + public String getRecipeId(){ + return recipeId; + } + + @DynamoDBAttribute(attributeName = "userName") + public String getUserName(){ + return userName; + } + + @DynamoDBAttribute(attributeName = "profilePictureUrl") + public String getProfilePictureUrl(){ + return profilePictureUrl; + } + + @DynamoDBAttribute(attributeName = "comment") + public String getComment(){ + return comment; + } + + @DynamoDBAttribute(attributeName = "notificationType") + public String getNotificationType(){ + return notificationType; + } + + @DynamoDBAttribute(attributeName = "userId") + public String getUserId(){ + return userId; + } + + //Setters + public void setUserId(String userId){ + this.userId = userId; + } +} diff --git a/apps/api/src/main/java/com/fridgetoplate/model/Preference.java b/apps/api/src/main/java/com/fridgetoplate/model/Preference.java deleted file mode 100644 index 78f8a3f0..00000000 --- a/apps/api/src/main/java/com/fridgetoplate/model/Preference.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.fridgetoplate.model; - -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAutoGeneratedKey; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBHashKey; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable; -import lombok.Data; - -@Data -@DynamoDBTable(tableName = "preferences") -public class Preference { - - @DynamoDBHashKey - @DynamoDBAutoGeneratedKey - private String preferenceId; - - @DynamoDBAttribute - private String name; -} diff --git a/apps/api/src/main/java/com/fridgetoplate/model/Preferences.java b/apps/api/src/main/java/com/fridgetoplate/model/Preferences.java new file mode 100644 index 00000000..e0bd9c56 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/model/Preferences.java @@ -0,0 +1,27 @@ +package com.fridgetoplate.model; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBHashKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable; +import lombok.Data; + +@Data +@DynamoDBTable(tableName = "preferences") +public class Preferences { + + @DynamoDBHashKey(attributeName = "username") + private String username; + + @DynamoDBAttribute(attributeName = "darkMode") + private boolean darkMode; + + @DynamoDBAttribute(attributeName = "recommendNotif") + private boolean recommendNotif; + + @DynamoDBAttribute(attributeName = "viewsNotif") + private boolean viewsNotif; + + @DynamoDBAttribute(attributeName = "reviewsNotif") + private boolean reviewsNotif; + +} diff --git a/apps/api/src/main/java/com/fridgetoplate/model/Profile.java b/apps/api/src/main/java/com/fridgetoplate/model/Profile.java deleted file mode 100644 index 1442e053..00000000 --- a/apps/api/src/main/java/com/fridgetoplate/model/Profile.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.fridgetoplate.model; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAutoGeneratedKey; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBHashKey; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTypeConverted; -import com.fridgetoplate.utils.IngredientArrayConverter; -import com.fridgetoplate.utils.RecipeArrayConverter; - -import lombok.Data; - -@Data -@DynamoDBTable(tableName = "profiles") -public class Profile { - @DynamoDBHashKey - @DynamoDBAutoGeneratedKey - private String profileId; - - @DynamoDBAttribute - private String username; - - @DynamoDBAttribute - private String email; - - @DynamoDBAttribute - private String displayName; - - @DynamoDBAttribute - private String profilePicture; - - @DynamoDBAttribute - @DynamoDBTypeConverted(converter = IngredientArrayConverter.class) - private Ingredient[] ingredients; - - @DynamoDBAttribute - @DynamoDBTypeConverted(converter = RecipeArrayConverter.class) - private Recipe[] preferences; - - @DynamoDBAttribute - @DynamoDBTypeConverted(converter = RecipeArrayConverter.class) - private Recipe[] createdRecipes; - -} \ No newline at end of file diff --git a/apps/api/src/main/java/com/fridgetoplate/model/ProfileModel.java b/apps/api/src/main/java/com/fridgetoplate/model/ProfileModel.java new file mode 100644 index 00000000..a6aea9db --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/model/ProfileModel.java @@ -0,0 +1,46 @@ +package com.fridgetoplate.model; + +import java.util.List; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBHashKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable; +import com.fridgetoplate.interfaces.Profile; + +@DynamoDBTable(tableName = "profiles") +public class ProfileModel extends Profile { + + private List savedRecipeIds; + + @DynamoDBHashKey(attributeName = "username") + public String getUsername() { + return username; + } + + @DynamoDBAttribute(attributeName = "email") + public String getEmail() { + return email; + } + + @DynamoDBAttribute(attributeName = "display_name") + public String getDisplayName() { + return displayName; + } + + @DynamoDBAttribute(attributeName = "profile_picture") + public String getProfilePic() { + return profilePic; + } + + @DynamoDBAttribute(attributeName = "saved_recipes") + public List getSavedRecipes() { + return savedRecipeIds; + } + + // setters + public void setSavedRecipes(List savedRecipeIds) { + this.savedRecipeIds = savedRecipeIds; + } + + +} diff --git a/apps/api/src/main/java/com/fridgetoplate/model/Recipe.java b/apps/api/src/main/java/com/fridgetoplate/model/Recipe.java deleted file mode 100644 index 76bb4f4a..00000000 --- a/apps/api/src/main/java/com/fridgetoplate/model/Recipe.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.fridgetoplate.model; - -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAutoGeneratedKey; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBHashKey; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTypeConverted; -import com.fridgetoplate.utils.StringArrayConverter; -import com.fridgetoplate.utils.IngredientArrayConverter; -import com.fridgetoplate.utils.RecipeStepArrayConverter; - -import lombok.Data; - -@Data -@DynamoDBTable(tableName = "recipes") -public class Recipe { - - @DynamoDBHashKey - @DynamoDBAutoGeneratedKey - private String recipeId; - - @DynamoDBAttribute - private String name; - - @DynamoDBAttribute - private String recipeImage; - - @DynamoDBAttribute - private String difficulty; - - @DynamoDBAttribute - private Integer prepTime; - - @DynamoDBAttribute - private Integer numberOfServings; - - @DynamoDBAttribute - @DynamoDBTypeConverted(converter = StringArrayConverter.class) - private String[] tags; - - @DynamoDBAttribute - @DynamoDBTypeConverted(converter = IngredientArrayConverter.class) - private Ingredient[] ingredients; - - @DynamoDBAttribute - @DynamoDBTypeConverted(converter = RecipeStepArrayConverter.class) - private RecipeStep[] instructions; - - - -} diff --git a/apps/api/src/main/java/com/fridgetoplate/model/RecipeModel.java b/apps/api/src/main/java/com/fridgetoplate/model/RecipeModel.java new file mode 100644 index 00000000..90e2a91d --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/model/RecipeModel.java @@ -0,0 +1,94 @@ +package com.fridgetoplate.model; + +import java.util.List; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAutoGeneratedKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBHashKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapperFieldModel; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTyped; +import com.fridgetoplate.interfaces.Recipe; + +@DynamoDBTable(tableName = "recipes") +public class RecipeModel extends Recipe { + + private Integer views = 0; + + // The getters + @DynamoDBHashKey(attributeName = "recipeId") + @DynamoDBAutoGeneratedKey + public String getRecipeId() { + return recipeId; + } + + @DynamoDBAttribute(attributeName = "recipeImage") + public String getRecipeImage() { + return recipeImage; + } + + @DynamoDBAttribute(attributeName = "name") + public String getName() { + return name; + } + + @DynamoDBAttribute(attributeName = "tags") + public List getTags() { + return tags; + } + + @DynamoDBAttribute(attributeName = "meal") + public String getMeal() { + return meal; + } + + @DynamoDBAttribute(attributeName = "description") + public String getDescription() { + return description; + } + + + @DynamoDBAttribute(attributeName = "ingredients") + @DynamoDBTyped(DynamoDBMapperFieldModel.DynamoDBAttributeType.L) + public List getIngredients() { + return ingredients; + } + + @DynamoDBAttribute(attributeName = "steps") + public List getSteps() { + return steps; + } + + @DynamoDBAttribute(attributeName = "prepTime") + public Integer getPrepTime() { + return prepTime; + } + + @DynamoDBAttribute(attributeName = "servings") + public Integer getServings() { + return servings; + } + + @DynamoDBAttribute(attributeName = "difficulty") + public String getDifficulty() { + return difficulty; + } + + @DynamoDBAttribute(attributeName = "creator") + public String getCreator() { + return creator; + } + + + @DynamoDBAttribute(attributeName = "views") + @DynamoDBTyped(DynamoDBMapperFieldModel.DynamoDBAttributeType.N) + public Integer getViews() { + return views; + } + + // The setters + + public void setViews(Integer views) { + this.views = views; + } + +} diff --git a/apps/api/src/main/java/com/fridgetoplate/model/RecipeStep.java b/apps/api/src/main/java/com/fridgetoplate/model/RecipeStep.java deleted file mode 100644 index 1afd28a1..00000000 --- a/apps/api/src/main/java/com/fridgetoplate/model/RecipeStep.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.fridgetoplate.model; - - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class RecipeStep { - @JsonProperty("instructionHeading") - private String instructionHeading = "N/A"; - - @JsonProperty("instructionBody") - private String instructionBody; - -} diff --git a/apps/api/src/main/java/com/fridgetoplate/model/RecommendModel.java b/apps/api/src/main/java/com/fridgetoplate/model/RecommendModel.java new file mode 100644 index 00000000..e07c5ca0 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/model/RecommendModel.java @@ -0,0 +1,32 @@ +package com.fridgetoplate.model; + +import java.util.List; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAutoGeneratedKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBHashKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapperFieldModel; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTyped; +import com.fridgetoplate.interfaces.RecipePreferences; +import com.fridgetoplate.interfaces.Recommend; + +@DynamoDBTable(tableName = "recommends") +public class RecommendModel extends Recommend{ + + + @DynamoDBHashKey(attributeName = "username") + public String getUsername(){ + return username; + } + + @DynamoDBAttribute(attributeName = "ingredients") + public List getIngredients(){ + return ingredients; + } + + @DynamoDBAttribute(attributeName = "recipe_preferences") + //@DynamoDBTyped(DynamoDBMapperFieldModel.DynamoDBAttributeType.M) + private RecipePreferences recipePrefernces; + +} diff --git a/apps/api/src/main/java/com/fridgetoplate/model/Review.java b/apps/api/src/main/java/com/fridgetoplate/model/Review.java new file mode 100644 index 00000000..6585496b --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/model/Review.java @@ -0,0 +1,29 @@ +package com.fridgetoplate.model; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAutoGeneratedKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBHashKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBRangeKey; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable; + +import lombok.Data; +@Data +@DynamoDBTable(tableName = "reviews") +public class Review { + + @DynamoDBHashKey(attributeName = "recipeId") + private String recipeId; + + @DynamoDBRangeKey(attributeName = "reviewId") + @DynamoDBAutoGeneratedKey + private String reviewId; + + @DynamoDBAttribute(attributeName = "username") + private String username; + + @DynamoDBAttribute(attributeName = "rating") + private float rating; + + @DynamoDBAttribute(attributeName = "description") + private String description; +} diff --git a/apps/api/src/main/java/com/fridgetoplate/repository/ExploreRepository.java b/apps/api/src/main/java/com/fridgetoplate/repository/ExploreRepository.java new file mode 100644 index 00000000..47477455 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/repository/ExploreRepository.java @@ -0,0 +1,237 @@ +package com.fridgetoplate.repository; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBSaveExpression; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBScanExpression; +import com.amazonaws.services.dynamodbv2.datamodeling.PaginatedScanList; +import com.amazonaws.services.dynamodbv2.model.AttributeValue; +import com.amazonaws.services.dynamodbv2.model.ExpectedAttributeValue; +import com.fridgetoplate.frontendmodels.RecipeFrontendModel; +import com.fridgetoplate.interfaces.Explore; +import com.fridgetoplate.interfaces.Recipe; +import com.fridgetoplate.model.Ingredient; +import com.fridgetoplate.model.RecipeModel; +import com.fridgetoplate.model.Review; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; +import org.springframework.web.bind.annotation.RequestBody; + +@Repository +public class ExploreRepository { + + @Autowired + private DynamoDBMapper dynamoDBMapper; + + public RecipeFrontendModel findById(String id){ + + /* + * Getting the Recipe Response + */ + + // Declaring the Recipe Response object + RecipeFrontendModel recipeResponse = new RecipeFrontendModel(); + + + // Find the Recipe model + RecipeModel recipeModel = dynamoDBMapper.load(RecipeModel.class, id); + + if(recipeModel == null) { + return null; + } + + // Getting recipe attributes + String recipeId = recipeModel.getRecipeId(); + String difficulty = recipeModel.getDifficulty(); + String recipeImage = recipeModel.getRecipeImage(); + String name = recipeModel.getName(); + List tags = recipeModel.getTags(); + String meal = recipeModel.getMeal(); + String description = recipeModel.getDescription(); + List ingredients = recipeModel.getIngredients(); + Integer prepTime = recipeModel.getPrepTime(); + List instructions = recipeModel.getSteps(); + String creator = recipeModel.getCreator(); + Integer servings = recipeModel.getServings(); + + // Creating recipe response + recipeResponse.setRecipeId(recipeId); + recipeResponse.setDifficulty(difficulty); + recipeResponse.setRecipeImage(recipeImage); + recipeResponse.setName(name); + recipeResponse.setTags(tags); + recipeResponse.setMeal(meal); + recipeResponse.setDescription(description); + recipeResponse.setIngredients(ingredients); + recipeResponse.setPrepTime(prepTime); + recipeResponse.setSteps(instructions); + recipeResponse.setCreator(creator); + recipeResponse.setServings(servings); + + + /* + * Getting the Reviews + */ + + // Declaring the Reviews object + List reviews = this.getReviewsById(recipeId); + + // Adding the reviews to the recipe response + recipeResponse.setReviews(reviews); + + + + return recipeResponse; + } + + public List getReviewsById(String id) { + List reviews = new ArrayList<>(); + + PaginatedScanList scanResult = dynamoDBMapper.scan(Review.class, new DynamoDBScanExpression()); + + for (Review review : scanResult) { + + if (review.getRecipeId().equals(id)) { + reviews.add(review); + } + } + + return reviews; + } + + + public List findAll(){ + List recipes = new ArrayList<>(); + + PaginatedScanList scanResult = dynamoDBMapper.scan(RecipeModel.class, new DynamoDBScanExpression()); + + for (RecipeModel recipe : scanResult) { + + RecipeFrontendModel response = findById(recipe.getRecipeId()); + + if(response != null) { + recipes.add(response); + } + } + + return recipes; + } + + public List findBySearch(Explore searchObject){ + List recipes = new ArrayList<>(); + + PaginatedScanList scanResult = dynamoDBMapper.scan(RecipeModel.class, new DynamoDBScanExpression()); + + for (RecipeModel recipe : scanResult) { + + RecipeFrontendModel response = findById(recipe.getRecipeId()); + + if(response != null) { + recipes.add(response); + } + } + + + String search = searchObject.getSearch(); + + String type = searchObject.getType(); + + List tags = searchObject.getTags(); + + String difficulty = searchObject.getDifficulty(); + + if(!search.equalsIgnoreCase("")) + fiterBySearch(search, recipes); + + if(!type.equalsIgnoreCase("")) + fiterByType(type, recipes); + + if(tags.size() != 0) + fiterByTags(tags, recipes); + + if(!difficulty.equalsIgnoreCase("Any")) + fiterByDifficulty(difficulty, recipes); + + return recipes; + } + + private void fiterBySearch(String search, List recipes) { + + for (Iterator iterator = recipes.iterator(); iterator.hasNext(); ) { + + RecipeFrontendModel recipe = iterator.next(); + + if (recipe.getName() != null) { + + if(!recipe.getName().contains(search)) { + iterator.remove(); + + } + } + + } + } + + private void fiterByType(String type, List recipes) { + + for (Iterator iterator = recipes.iterator(); iterator.hasNext(); ) { + + RecipeFrontendModel recipe = iterator.next(); + + if (recipe.getMeal() != null) { + + if(!recipe.getMeal().contains(type)) { + iterator.remove(); + + } + } + + } + } + + private void fiterByTags(List tags, List recipes) { + + for (RecipeFrontendModel recipe : recipes) { + + HashSet results = new HashSet<>(recipe.getTags()); + boolean anyItemsExist = false; + + for (String item : tags) { + if (results.contains(item)) { + anyItemsExist = true; + break; + } + } + + if(anyItemsExist == false){ + recipes.remove(recipe); + } + } + } + + private void fiterByDifficulty(String difficulty, List recipes) { + + for (Iterator iterator = recipes.iterator(); iterator.hasNext(); ) { + + RecipeFrontendModel recipe = iterator.next(); + + if (recipe.getDifficulty() != null) { + + if(!recipe.getDifficulty().contains(difficulty)) { + iterator.remove(); + + } + } + + } + + } + + + +} + diff --git a/apps/api/src/main/java/com/fridgetoplate/repository/IngredientRepository.java b/apps/api/src/main/java/com/fridgetoplate/repository/IngredientRepository.java deleted file mode 100644 index e1650bf4..00000000 --- a/apps/api/src/main/java/com/fridgetoplate/repository/IngredientRepository.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.fridgetoplate.repository; -import java.util.List; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Repository; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBSaveExpression; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBScanExpression; -import com.amazonaws.services.dynamodbv2.model.AttributeValue; -import com.amazonaws.services.dynamodbv2.model.ExpectedAttributeValue; -import com.fridgetoplate.model.Ingredient; - -@Repository -public class IngredientRepository { - @Autowired - private DynamoDBMapper dynamoDBMapper; - - public Ingredient save(Ingredient ingredient){ - dynamoDBMapper.save(ingredient); - return ingredient; - } - - public Ingredient[] saveAll(Ingredient[] ingredients){ - for(Ingredient ingredient : ingredients) - { - dynamoDBMapper.save(ingredient); - } - return ingredients; - } - - public Ingredient findById(String id){ - return dynamoDBMapper.load(Ingredient.class, id); - } - - public List findAll(){ - return dynamoDBMapper.scan(Ingredient.class, new DynamoDBScanExpression()); - } - - public Ingredient update(String id, Ingredient ingredient){ - - Ingredient ingredientData = dynamoDBMapper.load(Ingredient.class, id); - - System.out.println(ingredientData); - - if(ingredientData == null) - return null; - - - if(ingredient.getName() != null) { - ingredientData.setName(ingredient.getName()); - } - - dynamoDBMapper.save(ingredientData, - new DynamoDBSaveExpression() - .withExpectedEntry("ingredientId", - new ExpectedAttributeValue( - new AttributeValue().withS(id) - ))); - return ingredientData; - } - - public String delete(String id){ - Ingredient ingredient = dynamoDBMapper.load(Ingredient.class, id); - dynamoDBMapper.delete(ingredient); - return "Profile deleted successfully:: " + id; - } -} diff --git a/apps/api/src/main/java/com/fridgetoplate/repository/MealPlanRepository.java b/apps/api/src/main/java/com/fridgetoplate/repository/MealPlanRepository.java new file mode 100644 index 00000000..5017d741 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/repository/MealPlanRepository.java @@ -0,0 +1,87 @@ +package com.fridgetoplate.repository; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBScanExpression; +import com.amazonaws.services.dynamodbv2.datamodeling.PaginatedScanList; +import com.fridgetoplate.frontendmodels.MealPlanFrontendModel; +import com.fridgetoplate.interfaces.RecipeDesc; +import com.fridgetoplate.model.MealPlanModel; +import com.fridgetoplate.model.RecipeModel; + +@Repository +public class MealPlanRepository { + + @Autowired + private DynamoDBMapper dynamoDBMapper; + + public MealPlanFrontendModel save(MealPlanModel mealPlan) { + + RecipeDesc recipe = null; + dynamoDBMapper.save(mealPlan); + MealPlanFrontendModel model = new MealPlanFrontendModel(); + if(mealPlan.getBreakfastId() != null && !mealPlan.getBreakfastId().isEmpty()) { + String id = mealPlan.getBreakfastId(); + recipe = dynamoDBMapper.load(RecipeModel.class, id); + model.setBreakfast(recipe); + } + else { + model.setBreakfast(null); + } + + if(mealPlan.getLunchId() != null && !mealPlan.getLunchId().isEmpty()) { + String id = mealPlan.getLunchId(); + recipe = dynamoDBMapper.load(RecipeModel.class,id); + model.setLunch(recipe); + } + else { + model.setLunch(null); + } + + if(mealPlan.getDinnerId() != null && !mealPlan.getDinnerId().isEmpty()) { + String id = mealPlan.getDinnerId(); + recipe = dynamoDBMapper.load(RecipeModel.class,id); + model.setDinner(recipe); + } + else { + model.setDinner(null); + } + + if(mealPlan.getSnackId() != null && !mealPlan.getSnackId().isEmpty()) { + String id = mealPlan.getSnackId(); + recipe = dynamoDBMapper.load(RecipeModel.class, id); + model.setSnack(recipe); + } + else { + model.setSnack(null); + } + + model.setUsername(mealPlan.getUsername()); + model.setDate(mealPlan.getDate()); + + return model; + } + + public List findAll() { + return dynamoDBMapper.scan(MealPlanModel.class, new DynamoDBScanExpression()); + } + + public MealPlanModel findByUsername(String username) { + PaginatedScanList scanResult = dynamoDBMapper.scan(MealPlanModel.class, new DynamoDBScanExpression()); + MealPlanModel modelData = null; + for (MealPlanModel model : scanResult) { + if(model.getUsername().equals(username)){ + modelData = model; + break; + } + } + return modelData; + } + + public void setDynamoDBMapper(DynamoDBMapper dynamoDBMapper){ + this.dynamoDBMapper = dynamoDBMapper; + } +} \ No newline at end of file diff --git a/apps/api/src/main/java/com/fridgetoplate/repository/NotificationsRepository.java b/apps/api/src/main/java/com/fridgetoplate/repository/NotificationsRepository.java new file mode 100644 index 00000000..ec092123 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/repository/NotificationsRepository.java @@ -0,0 +1,103 @@ +package com.fridgetoplate.repository; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBScanExpression; +import com.amazonaws.services.dynamodbv2.datamodeling.PaginatedScanList; +import com.amazonaws.services.dynamodbv2.model.AttributeValue; +import com.fridgetoplate.frontendmodels.NotificationsResponseModel; +import com.fridgetoplate.model.NotificationModel; + +@Repository +public class NotificationsRepository { + + @Autowired + private DynamoDBMapper dynamoDBMapper; + + public NotificationModel save(NotificationModel notification){ + dynamoDBMapper.save(notification); + return notification; + } + + public NotificationsResponseModel findAll(String userId){ + + List generalNotifications = new ArrayList<>(); + + List recommendationNotifications = new ArrayList<>(); + + NotificationsResponseModel notifications = new NotificationsResponseModel(); + + HashMap eav = new HashMap(); + eav.put(":userId", new AttributeValue().withS(userId)); + + DynamoDBScanExpression scanExpression = new DynamoDBScanExpression().withFilterExpression("userId=:userId").withExpressionAttributeValues(eav); + + PaginatedScanList scanResult = dynamoDBMapper.scan(NotificationModel.class, scanExpression); + + for (NotificationModel notification : scanResult) { + + if(notification != null) { + if(notification.getNotificationType().equals("general")) + generalNotifications.add(notification); + else + recommendationNotifications.add(notification); + } + } + + notifications.setGeneral(generalNotifications); + + notifications.setRecommendations(recommendationNotifications); + + return notifications; + } + + public String delete(String notificationId){ + NotificationModel notification = dynamoDBMapper.load(NotificationModel.class, notificationId); + + dynamoDBMapper.delete(notification); + + return "Notification deleted successfully " + notificationId; + } + + public String clearNotifications(String userId){ + List notifications = new ArrayList<>(); + + HashMap eav = new HashMap(); + eav.put(":userId", new AttributeValue().withS(userId)); + + DynamoDBScanExpression scanExpression = new DynamoDBScanExpression().withFilterExpression("userId=:userId").withExpressionAttributeValues(eav); + + PaginatedScanList scanResult = dynamoDBMapper.scan(NotificationModel.class, scanExpression); + + for(int i = 0; i < scanResult.size(); i++){ + this.delete( scanResult.get(i).getNotificationId() ); + } + + return "Notifications for " + userId + " deleted successfully"; + } + + public String clearAllNotificationOfType(String userId, String type){ + List notifications = new ArrayList<>(); + + HashMap eav = new HashMap(); + eav.put(":userId", new AttributeValue().withS(userId)); + eav.put(":notificationType", new AttributeValue().withS(type)); + + DynamoDBScanExpression scanExpression = new DynamoDBScanExpression().withFilterExpression("userId=:userId AND notificationType=:notificationType").withExpressionAttributeValues(eav); + + PaginatedScanList scanResult = dynamoDBMapper.scan(NotificationModel.class, scanExpression); + + for(int i = 0; i < scanResult.size(); i++){ + this.delete( scanResult.get(i).getNotificationId() ); + } + + return "All "+ type + " Notifications for " + userId + " deleted successfully"; + } + +} diff --git a/apps/api/src/main/java/com/fridgetoplate/repository/PreferenceRepository.java b/apps/api/src/main/java/com/fridgetoplate/repository/PreferenceRepository.java deleted file mode 100644 index 6360b0f3..00000000 --- a/apps/api/src/main/java/com/fridgetoplate/repository/PreferenceRepository.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.fridgetoplate.repository; - -import java.util.List; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Repository; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBSaveExpression; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBScanExpression; -import com.amazonaws.services.dynamodbv2.model.AttributeValue; -import com.amazonaws.services.dynamodbv2.model.ExpectedAttributeValue; -import com.fridgetoplate.model.Preference; - -@Repository -public class PreferenceRepository { - @Autowired - private DynamoDBMapper dynamoDBMapper; - - public Preference save(Preference preference){ - dynamoDBMapper.save(preference); - return preference; - } - - public Preference findById(String id){ - return dynamoDBMapper.load(Preference.class, id); - } - - public List findAll(){ - return dynamoDBMapper.scan(Preference.class, new DynamoDBScanExpression()); - } - - public Preference update(String id, Preference preference){ - - Preference preferenceData = dynamoDBMapper.load(Preference.class, id); - - System.out.println(preferenceData); - - if(preferenceData == null) - return null; - - - if(preference.getName() != null) { - preferenceData.setName(preference.getName()); - } - - dynamoDBMapper.save(preferenceData, - new DynamoDBSaveExpression() - .withExpectedEntry("preferenceId", - new ExpectedAttributeValue( - new AttributeValue().withS(id) - ))); - return preferenceData; - } - - public String delete(String id){ - Preference preference = dynamoDBMapper.load(Preference.class, id); - dynamoDBMapper.delete(preference); - return "Profile deleted successfully:: " + id; - } -} diff --git a/apps/api/src/main/java/com/fridgetoplate/repository/PreferencesRepository.java b/apps/api/src/main/java/com/fridgetoplate/repository/PreferencesRepository.java new file mode 100644 index 00000000..114c302f --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/repository/PreferencesRepository.java @@ -0,0 +1,52 @@ +package com.fridgetoplate.repository; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBSaveExpression; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBScanExpression; +import com.amazonaws.services.dynamodbv2.model.AttributeValue; +import com.amazonaws.services.dynamodbv2.model.ExpectedAttributeValue; +import com.fridgetoplate.model.Preferences; + +@Repository +public class PreferencesRepository { + @Autowired + private DynamoDBMapper dynamoDBMapper; + + public Preferences save(Preferences preferences){ + dynamoDBMapper.save(preferences); + return preferences; + } + + public Preferences findByName(String username){ + return dynamoDBMapper.load(Preferences.class, username); + } + + public List findAll(){ + return dynamoDBMapper.scan(Preferences.class, new DynamoDBScanExpression()); + } + + public Preferences update(String username, Preferences preferences){ + + if(preferences == null) + return null; + + + dynamoDBMapper.save(preferences, + new DynamoDBSaveExpression() + .withExpectedEntry("username", + new ExpectedAttributeValue( + new AttributeValue().withS(username) + ))); + + return preferences; + } + + public String delete(String username){ + Preferences preferences = dynamoDBMapper.load(Preferences.class, username); + dynamoDBMapper.delete(preferences); + return "Preferences deleted successfully: " + username; + } +} diff --git a/apps/api/src/main/java/com/fridgetoplate/repository/ProfileRepository.java b/apps/api/src/main/java/com/fridgetoplate/repository/ProfileRepository.java index a53c96b4..015de410 100644 --- a/apps/api/src/main/java/com/fridgetoplate/repository/ProfileRepository.java +++ b/apps/api/src/main/java/com/fridgetoplate/repository/ProfileRepository.java @@ -1,14 +1,24 @@ package com.fridgetoplate.repository; +import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Repository; import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper; import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBSaveExpression; import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBScanExpression; +import com.amazonaws.services.dynamodbv2.datamodeling.PaginatedScanList; import com.amazonaws.services.dynamodbv2.model.AttributeValue; import com.amazonaws.services.dynamodbv2.model.ExpectedAttributeValue; -import com.fridgetoplate.model.Profile; +import com.fridgetoplate.frontendmodels.MealPlanFrontendModel; +import com.fridgetoplate.frontendmodels.ProfileFrontendModel; +import com.fridgetoplate.interfaces.Profile; +import com.fridgetoplate.interfaces.RecipeDesc; +import com.fridgetoplate.model.MealPlanModel; +import com.fridgetoplate.model.ProfileModel; +import com.fridgetoplate.model.RecipeModel; +import com.fridgetoplate.model.Review; @Repository public class ProfileRepository { @@ -16,75 +26,211 @@ public class ProfileRepository { @Autowired private DynamoDBMapper dynamoDBMapper; - public Profile save(Profile profile){ - dynamoDBMapper.save(profile); + public ProfileFrontendModel save(ProfileFrontendModel profile){ + ProfileModel model = new ProfileModel(); + model.setUsername(profile.getUsername()); + model.setDisplayName(profile.getDisplayName()); + model.setEmail(profile.getEmail()); + model.setProfilePic(profile.getProfilePic()); + dynamoDBMapper.save(model); return profile; } - public Profile findById(String id){ - return dynamoDBMapper.load(Profile.class, id); + public ProfileFrontendModel findByName(String username) { + /* + * Getting the Profile Response + */ + + // Declaring the Profile Response object + ProfileFrontendModel profileResponse = new ProfileFrontendModel(); + + // Find the Profile model + ProfileModel profileModel = dynamoDBMapper.load(ProfileModel.class, username); + + if(profileModel == null) { + return null; + } + + // Getting profile attributes + String displayName = profileModel.getDisplayName(); + String email = profileModel.getEmail(); + List savedRecipes = this.getSavedRecipes(profileModel.getSavedRecipes()); + List createdRecipes = this.getCreateRecipes(username); + String profilePicture = profileModel.getProfilePic(); + + // Creating profile response + profileResponse.setUsername(username); + profileResponse.setDisplayName(displayName); + profileResponse.setEmail(email); + profileResponse.setSavedRecipes(savedRecipes); + profileResponse.setCreatedRecipes(createdRecipes); + profileResponse.setProfilePic(profilePicture); + + /* + * Getting the MealPan response + */ + + // Find Meal + String date = LocalDate.now().toString(); + + MealPlanModel mealPlanModel = dynamoDBMapper.load(MealPlanModel.class, username, date); + + if(mealPlanModel != null) { + + // Declare the response object + MealPlanFrontendModel mealPlanResponse = new MealPlanFrontendModel(); + + // creating response + mealPlanResponse.setUsername(username); + + String breakFastId = mealPlanModel.getBreakfastId(); + + RecipeDesc breakfast = null; + if (breakFastId != null && breakFastId != "") { + breakfast = dynamoDBMapper.load(RecipeModel.class, breakFastId); + } + + String lunchId = mealPlanModel.getLunchId(); + RecipeDesc lunch = null; + if (lunchId != null && lunchId != "") { + lunch = dynamoDBMapper.load(RecipeModel.class, lunchId); + } + + String dinnerId = mealPlanModel.getDinnerId(); + RecipeDesc dinner = null; + if (dinnerId != null && dinnerId != "") { + dinner = dynamoDBMapper.load(RecipeModel.class, dinnerId); + } + + String snackId = mealPlanModel.getSnackId(); + RecipeDesc snack = null; + if (snackId != null && snackId != "") { + snack = dynamoDBMapper.load(RecipeModel.class, snackId); + } + + // Creating the mealPlanResponse + mealPlanResponse.setDate(date); + mealPlanResponse.setBreakfast(breakfast); + mealPlanResponse.setLunch(lunch); + mealPlanResponse.setDinner(dinner); + mealPlanResponse.setSnack(snack); + + // saving meal plan response to profile response + profileResponse.setCurrMealPlan(mealPlanResponse); + } else { + profileResponse.setCurrMealPlan(null); + } + + return profileResponse; } - public List findAll(){ - return dynamoDBMapper.scan(Profile.class, new DynamoDBScanExpression()); + public List findAll(){ + return dynamoDBMapper.scan(ProfileFrontendModel.class, new DynamoDBScanExpression()); } - public Profile update(String id, Profile profile){ + public ProfileFrontendModel update(String username, ProfileFrontendModel profile){ //Retrieve the profile of the specified ID - Profile profileData = dynamoDBMapper.load(Profile.class, id); - - System.out.println("profileData"); - System.out.println(profileData); + ProfileModel profileData = dynamoDBMapper.load(ProfileModel.class, username); //Return null if user profile does not exist if(profileData == null) return null; - - - //Set the new details of the user profile - if(profile.getIngredients() != null) { - profileData.setIngredients(profile.getIngredients()); - } - - if(profile.getPreferences() != null) { - profileData.setPreferences(profile.getPreferences()); - } - - if(profile.getCreatedRecipes() != null) { - profile.setCreatedRecipes(profile.getCreatedRecipes()); - } - - if(profile.getProfilePicture() != null) { - profileData.setProfilePicture(profile.getProfilePicture()); + + if(profile.getDisplayName() != null) { + profileData.setDisplayName(profile.getDisplayName()); } - if(profile.getUsername() != null) { - profileData.setUsername(profile.getUsername()); + if(profile.getProfilePic() != null) { + profileData.setProfilePic(profile.getProfilePic()); } if(profile.getEmail() != null) { profileData.setEmail(profile.getEmail()); } - if(profile.getDisplayName() != null) { - profileData.setDisplayName(profile.getDisplayName()); + if(profile.getSavedRecipes() != null) { + profileData.setSavedRecipes(this.getSavedRecipeIds(profile.getSavedRecipes())); } dynamoDBMapper.save(profileData, new DynamoDBSaveExpression() - .withExpectedEntry("profileId", + .withExpectedEntry("username", new ExpectedAttributeValue( - new AttributeValue().withS(id) + new AttributeValue().withS(username) ))); - return profileData; + + return profile; } public String delete(String id){ Profile profile = dynamoDBMapper.load(Profile.class, id); dynamoDBMapper.delete(profile); - return "Profile deleted successfully:: " + id; + return "Profile deleted successfully: " + id; } - + public List getReviewsByRecipeId(String id) { + List reviews = new ArrayList<>(); + + PaginatedScanList scanResult = dynamoDBMapper.scan(Review.class, new DynamoDBScanExpression()); + + for (Review review : scanResult) { + + if (review.getRecipeId().equals(id)) { + reviews.add(review); + } + } + + return reviews; + } + + private List getCreateRecipes(String username) { + List recipes = new ArrayList<>(); + PaginatedScanList scanResult = dynamoDBMapper.scan(RecipeModel.class, new DynamoDBScanExpression()); + + + for (RecipeModel recipeModel : scanResult) { + if (recipeModel.getCreator().equals(username)) { + recipes.add(recipeModel); + } + } + + + return recipes; + } + + + private List getSavedRecipes(List ids) { + + + List recipes = new ArrayList<>(); + + if(ids == null || ids.isEmpty()) { + return recipes; + } + + PaginatedScanList scanResult = dynamoDBMapper.scan(RecipeModel.class, new DynamoDBScanExpression()); + + for (String id : ids) { + for (RecipeModel recipeModel : scanResult) { + if (recipeModel.getRecipeId().equals(id)) { + recipes.add(recipeModel); + } + } + } + + return recipes; + } + + private List getSavedRecipeIds(List ids) { + List savedIds = new ArrayList<>(); + if(ids == null || ids.isEmpty()) { + return savedIds; + } + + for (RecipeDesc recipe : ids) { + savedIds.add(recipe.getRecipeId()); + } + return savedIds; + } } diff --git a/apps/api/src/main/java/com/fridgetoplate/repository/RecipeRepository.java b/apps/api/src/main/java/com/fridgetoplate/repository/RecipeRepository.java index 5bd2fd2d..2cc50137 100644 --- a/apps/api/src/main/java/com/fridgetoplate/repository/RecipeRepository.java +++ b/apps/api/src/main/java/com/fridgetoplate/repository/RecipeRepository.java @@ -2,11 +2,21 @@ import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper; import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBSaveExpression; import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBScanExpression; +import com.amazonaws.services.dynamodbv2.datamodeling.PaginatedScanList; import com.amazonaws.services.dynamodbv2.model.AttributeValue; import com.amazonaws.services.dynamodbv2.model.ExpectedAttributeValue; -import com.fridgetoplate.model.Recipe; +import com.fridgetoplate.frontendmodels.RecipeFrontendModel; +import com.fridgetoplate.frontendmodels.RecipePreferencesFrontendModel; +import com.fridgetoplate.interfaces.Recipe; +import com.fridgetoplate.model.Ingredient; +import com.fridgetoplate.model.ProfileModel; +import com.fridgetoplate.model.RecipeModel; +import com.fridgetoplate.model.Review; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Repository; @@ -16,70 +26,317 @@ public class RecipeRepository { @Autowired private DynamoDBMapper dynamoDBMapper; - public Recipe save(Recipe recipe){ - dynamoDBMapper.save(recipe); + @Autowired + private ProfileRepository profileRepository; + + public RecipeFrontendModel save(RecipeFrontendModel recipe){ + RecipeModel model = new RecipeModel(); + model.setRecipeId(recipe.getRecipeId()); + model.setDifficulty(recipe.getDifficulty()); + model.setRecipeImage(recipe.getRecipeImage()); + model.setName(recipe.getName()); + model.setTags(recipe.getTags()); + model.setMeal(recipe.getMeal()); + model.setDescription(recipe.getDescription()); + model.setIngredients(recipe.getIngredients()); + model.setPrepTime(recipe.getPrepTime()); + model.setSteps(recipe.getSteps()); + model.setCreator(recipe.getCreator()); + model.setServings(recipe.getServings()); + model.setViews(0); + dynamoDBMapper.save(model); + + recipe.setRecipeId(model.getRecipeId()); return recipe; } + + public RecipeModel[] saveBatch(RecipeModel[] recipeList){ + if(recipeList.length != 0) + { + for(int i = 0; i < recipeList.length; i++){ + dynamoDBMapper.save(recipeList[i]); + } + } + + return recipeList; + } + + public RecipeFrontendModel findById(String id){ + + /* + * Getting the Recipe Response + */ + + // Declaring the Recipe Response object + RecipeFrontendModel recipeResponse = new RecipeFrontendModel(); + + + // Find the Recipe model + RecipeModel recipeModel = dynamoDBMapper.load(RecipeModel.class, id); + + if(recipeModel == null) { + return null; + } + + // Getting recipe attributes + String recipeId = recipeModel.getRecipeId(); + String difficulty = recipeModel.getDifficulty(); + String recipeImage = recipeModel.getRecipeImage(); + String name = recipeModel.getName(); + List tags = recipeModel.getTags(); + String meal = recipeModel.getMeal(); + String description = recipeModel.getDescription(); + List ingredients = recipeModel.getIngredients(); + Integer prepTime = recipeModel.getPrepTime(); + List instructions = recipeModel.getSteps(); + String creator = recipeModel.getCreator(); + Integer servings = recipeModel.getServings(); - public Recipe findById(String id){ - return dynamoDBMapper.load(Recipe.class, id); + // Creating recipe response + recipeResponse.setRecipeId(recipeId); + recipeResponse.setDifficulty(difficulty); + recipeResponse.setRecipeImage(recipeImage); + recipeResponse.setName(name); + recipeResponse.setTags(tags); + recipeResponse.setMeal(meal); + recipeResponse.setDescription(description); + recipeResponse.setIngredients(ingredients); + recipeResponse.setPrepTime(prepTime); + recipeResponse.setSteps(instructions); + recipeResponse.setCreator(creator); + recipeResponse.setServings(servings); + + + /* + * Getting the Reviews + */ + + // Declaring the Reviews object + List reviews = this.getReviewsById(recipeId); + + // Adding the reviews to the recipe response + recipeResponse.setReviews(reviews); + + + + return recipeResponse; } - public List findAll(){ - return dynamoDBMapper.scan(Recipe.class, new DynamoDBScanExpression()); + public List findAll(){ + List recipes = new ArrayList<>(); + + PaginatedScanList scanResult = dynamoDBMapper.scan(RecipeModel.class, new DynamoDBScanExpression()); + + for (RecipeModel recipe : scanResult) { + + RecipeFrontendModel response = findById(recipe.getRecipeId()); + if(response != null) { + recipes.add(response); + } + } + + return recipes; } - public Recipe update(String id, Recipe recipe){ + public List findAllByPreferences(RecipePreferencesFrontendModel recipePreferences, List userIngredients){ + + //Build Expression + List recipes = new ArrayList<>(); + + HashMap eav = new HashMap(); + + String querySrting = ""; + + if(recipePreferences.getDifficulty() != null){ + eav.put(":difficulty", new AttributeValue().withS(recipePreferences.getDifficulty())); + + querySrting += "difficulty=:difficulty"; + } + + if(recipePreferences.getMeal() != null){ + eav.put(":meal", new AttributeValue().withS(recipePreferences.getMeal())); + + if(querySrting.length() != 0){ + querySrting += " AND meal=:meal"; + } else { + querySrting += "meal=:meal"; + } + } + + if(recipePreferences.getRating() != null){ + eav.put(":rating", new AttributeValue().withS(recipePreferences.getRating())); - Recipe recipeData = dynamoDBMapper.load(Recipe.class, id); + if(querySrting.length() != 0){ + querySrting += " AND rating=:rating"; + } else { + querySrting += "rating=:rating"; + } - if(recipe.getIngredients() != null) { - recipeData.setIngredients(recipe.getIngredients()); } + + if(recipePreferences.getServings() != null){ + eav.put(":servings", new AttributeValue().withS(recipePreferences.getServings())); + + if(querySrting.length() != 0){ + querySrting += " AND servings=:servings"; + } else { + querySrting += "servings=:servings"; + } - if(recipe.getInstructions() != null) { - recipeData.setInstructions(recipe.getInstructions()); } + + if(recipePreferences.getPrepTime() != null){ + eav.put(":prepTime", new AttributeValue().withS(recipePreferences.getPrepTime())); + + if(querySrting.length() != 0){ + querySrting += " AND prepTime=:prepTime"; + } else { + querySrting += "prepTime=:prepTime"; + } - if(recipe.getName() != null) { - recipeData.setName(recipe.getName()); } + + + String keywordQueryString = ""; + + if(recipePreferences.getKeywords() != null && recipePreferences.getKeywords().length != 0){ + + String [] keywordArray = recipePreferences.getKeywords(); + + for(int i = 0; i < keywordArray.length; i++){ + String keywordString = keywordArray[i].replace("\s", keywordQueryString).strip(); - if(recipe.getDifficulty() != null) { - recipeData.setDifficulty(recipe.getDifficulty()); + eav.put(":val_" + keywordString,new AttributeValue().withS(keywordString)); + if(i == 0){ + keywordQueryString = keywordQueryString + "contains(tags, :val_" + keywordString + ")"; + } + else{ + keywordQueryString = keywordQueryString + " OR contains(tags, :val_" + keywordString + ")"; + } + } } - if(recipe.getPrepTime() != null) { - recipeData.setPrepTime(recipe.getPrepTime()); + String ingredientQueryString = ""; + + if(userIngredients != null && userIngredients.size() != 0){ + + for(int i = 0; i < userIngredients.size(); i++){ + String ingredientName = userIngredients.get(i).getName().strip().split(",")[0].replace("\s", ""); + + eav.put(":val_" + ingredientName , new AttributeValue().withS(ingredientName)); + if(i == 0){ + keywordQueryString = keywordQueryString + "contains(ingredients, :val_" + ingredientName + ")"; + } + else{ + keywordQueryString = keywordQueryString + " OR contains(ingredients, :val_" + ingredientName + ")"; + } + } } + + if(!keywordQueryString.isBlank()){ + if(querySrting.isEmpty()) + querySrting += keywordQueryString; - if(recipe.getNumberOfServings() != null) { - recipeData.setNumberOfServings(recipe.getNumberOfServings()); + else{ + querySrting += " AND " + keywordQueryString; + } } - if(recipe.getTags() != null) { - recipeData.setTags(recipe.getTags()); + if(!ingredientQueryString.isBlank()){ + if(querySrting.isEmpty()) + querySrting += ingredientQueryString; + + else{ + querySrting += " AND " + ingredientQueryString; + } } - if(recipe.getRecipeImage() != null) { - recipeData.setRecipeImage(recipe.getRecipeImage()); + //Filter Expression + DynamoDBScanExpression scanExpression = new DynamoDBScanExpression().withFilterExpression(querySrting).withExpressionAttributeValues(eav); + + + PaginatedScanList scanResult = dynamoDBMapper.scan(RecipeModel.class, scanExpression); + + for (RecipeModel recipe : scanResult) { + + RecipeFrontendModel response = findById(recipe.getRecipeId()); + if(response != null) { + recipes.add(response); + } } + return recipes; + } + + public RecipeModel update(String id, RecipeModel recipe){ - dynamoDBMapper.save(recipeData, + dynamoDBMapper.save(recipe, new DynamoDBSaveExpression() .withExpectedEntry("recipeId", new ExpectedAttributeValue( new AttributeValue().withS(id) ))); - return recipeData; + return recipe; } public String delete(String id){ - Recipe person = dynamoDBMapper.load(Recipe.class, id); - dynamoDBMapper.delete(person); + RecipeModel recipe = dynamoDBMapper.load(RecipeModel.class, id); + dynamoDBMapper.delete(recipe); return "Recipe deleted successfully:: " + id; } + public List getReviewsById(String id) { + List reviews = new ArrayList<>(); + + PaginatedScanList scanResult = dynamoDBMapper.scan(Review.class, new DynamoDBScanExpression()); + + for (Review review : scanResult) { + + if (review.getRecipeId().equals(id)) { + reviews.add(review); + } + } + + return reviews; + } + + public List getRecipesByUsername(String username) { + List recipes = new ArrayList<>(); + + PaginatedScanList scanResult = dynamoDBMapper.scan(RecipeModel.class, new DynamoDBScanExpression()); + + for (RecipeModel recipe : scanResult) { + + if (recipe.getCreator().equals(username)) { + RecipeFrontendModel response = findById(recipe.getRecipeId()); + if(response != null) { + recipes.add(response); + } + + } + } + + return recipes; + } + + public List getRecipesByRecipename(String recipename) { + List recipes = new ArrayList<>(); + + PaginatedScanList scanResult = dynamoDBMapper.scan(RecipeModel.class, new DynamoDBScanExpression()); + + for (RecipeModel recipe : scanResult) { + + if (recipe.getName().equals(recipename)) { + RecipeFrontendModel response = findById(recipe.getRecipeId()); + if(response != null) { + recipes.add(response); + } + + } + } + + return recipes; + } + } diff --git a/apps/api/src/main/java/com/fridgetoplate/repository/RecommendRepository.java b/apps/api/src/main/java/com/fridgetoplate/repository/RecommendRepository.java new file mode 100644 index 00000000..ede41803 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/repository/RecommendRepository.java @@ -0,0 +1,144 @@ +package com.fridgetoplate.repository; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBSaveExpression; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBScanExpression; +import com.amazonaws.services.dynamodbv2.datamodeling.PaginatedScanList; +import com.amazonaws.services.dynamodbv2.model.AttributeValue; +import com.amazonaws.services.dynamodbv2.model.ExpectedAttributeValue; +import com.fridgetoplate.frontendmodels.RecipePreferencesFrontendModel; +import com.fridgetoplate.frontendmodels.RecommendFrontendModel; +import com.fridgetoplate.interfaces.RecipePreferences; +import com.fridgetoplate.model.Ingredient; +import com.fridgetoplate.model.RecommendModel; + +@Repository +public class RecommendRepository { + @Autowired + private DynamoDBMapper dynamoDBMapper; + + public RecommendFrontendModel save(RecommendFrontendModel recommendObject){ + + RecommendModel model = new RecommendModel(); + + model.setUsername(recommendObject.getUsername()); + + model.setIngredients(recommendObject.getIngredients()); + + RecipePreferences preferences = new RecipePreferences(); + + if(recommendObject.getRecipePreferences().getDifficulty() != null && !recommendObject.getRecipePreferences().getDifficulty().isEmpty()) + preferences.setDifficulty(recommendObject.getRecipePreferences().getDifficulty()); + + if(recommendObject.getRecipePreferences().getMeal() != null && !recommendObject.getRecipePreferences().getMeal().isEmpty()) + preferences.setMeal(recommendObject.getRecipePreferences().getMeal()); + + if(recommendObject.getRecipePreferences().getPrepTime() != null && !recommendObject.getRecipePreferences().getPrepTime().isEmpty()) + preferences.setPrepTime(recommendObject.getRecipePreferences().getPrepTime()); + + if(recommendObject.getRecipePreferences().getServings() != null && !recommendObject.getRecipePreferences().getServings().isEmpty()) + preferences.setServings(recommendObject.getRecipePreferences().getServings()); + + if(recommendObject.getRecipePreferences().getRating() != null && !recommendObject.getRecipePreferences().getRating().isEmpty()) + preferences.setRating(recommendObject.getRecipePreferences().getRating()); + + if(recommendObject.getRecipePreferences().getKeywords() != null && recommendObject.getRecipePreferences().getKeywords().length != 0) + preferences.setKeywords(Arrays.asList( recommendObject.getRecipePreferences().getKeywords())); + + model.setRecipePreferences(preferences); + + dynamoDBMapper.save(model); + + return recommendObject; + } + + public RecommendFrontendModel getById(String username){ + RecommendFrontendModel recommendObject = new RecommendFrontendModel(); + + // Find the Recommend model + RecommendModel recommendModel = dynamoDBMapper.load(RecommendModel.class, username); + + if(recommendModel == null) { + RecommendFrontendModel emptyResponse = new RecommendFrontendModel(); + emptyResponse.setUsername(username); + emptyResponse.setIngredients(new ArrayList()); + emptyResponse.setPreferences(new RecipePreferencesFrontendModel()); + return emptyResponse; + } + + //Convert RecommendModel to Frontend model. + recommendObject.setUsername(recommendModel.getUsername()); + + recommendObject.setIngredients(recommendModel.getIngredients()); + + RecipePreferencesFrontendModel preferencesFrontendObject = new RecipePreferencesFrontendModel(); + + if(recommendModel.getRecipePreferences().getDifficulty() != null && !recommendModel.getRecipePreferences().getDifficulty().isEmpty()) + preferencesFrontendObject.setDifficulty(recommendModel.getRecipePreferences().getDifficulty()); + + if(recommendModel.getRecipePreferences().getMeal() != null && !recommendModel.getRecipePreferences().getMeal().isEmpty()) + preferencesFrontendObject.setMeal(recommendModel.getRecipePreferences().getMeal()); + + if(recommendModel.getRecipePreferences().getPrepTime() != null && !recommendModel.getRecipePreferences().getPrepTime().isEmpty()) + preferencesFrontendObject.setPrepTime(recommendModel.getRecipePreferences().getPrepTime()); + + if(recommendModel.getRecipePreferences().getServings() != null && !recommendModel.getRecipePreferences().getServings().isEmpty()) + preferencesFrontendObject.setServings(recommendModel.getRecipePreferences().getServings()); + + if(recommendModel.getRecipePreferences().getRating() != null && !recommendModel.getRecipePreferences().getRating().isEmpty()) + preferencesFrontendObject.setRating(recommendModel.getRecipePreferences().getRating()); + + if(recommendModel.getRecipePreferences().getKeywords() != null && recommendModel.getRecipePreferences().getKeywords().size() != 0) + preferencesFrontendObject.setKeywords( recommendModel.getRecipePreferences().getKeywords().toArray(new String[recommendModel.getRecipePreferences().getKeywords().size()]) ); + + recommendObject.setPreferences(preferencesFrontendObject); + + return recommendObject; + } + + public RecommendFrontendModel updateRecommendPreferences(RecommendFrontendModel userPreferences){ + RecommendModel updatedRecommend = new RecommendModel(); + + updatedRecommend.setUsername(userPreferences.getUsername()); + + updatedRecommend.setIngredients(userPreferences.getIngredients()); + + RecipePreferences preferences = new RecipePreferences(); + + RecipePreferencesFrontendModel preferencesFrontendObject = userPreferences.getRecipePreferences(); + + if(preferencesFrontendObject.getDifficulty() != null && !preferencesFrontendObject.getDifficulty().isEmpty()) + preferences.setDifficulty(preferencesFrontendObject.getDifficulty()); + + if(preferencesFrontendObject.getMeal() != null && !preferencesFrontendObject.getMeal().isEmpty()) + preferences.setMeal(preferencesFrontendObject.getMeal()); + + if(preferencesFrontendObject.getPrepTime() != null && !preferencesFrontendObject.getPrepTime().isEmpty()) + preferences.setPrepTime(preferencesFrontendObject.getPrepTime()); + + if(preferencesFrontendObject.getServings() != null && !preferencesFrontendObject.getServings().isEmpty()) + preferences.setServings(preferencesFrontendObject.getServings()); + + if(preferencesFrontendObject.getRating() != null && !preferencesFrontendObject.getRating().isEmpty()) + preferences.setRating(preferencesFrontendObject.getRating()); + + if(preferencesFrontendObject.getKeywords() != null && preferencesFrontendObject.getKeywords().length != 0) + preferences.setKeywords(Arrays.asList( preferencesFrontendObject.getKeywords())); + + updatedRecommend.setRecipePreferences(preferences); + + dynamoDBMapper.save(updatedRecommend, new DynamoDBSaveExpression().withExpectedEntry("username", + new ExpectedAttributeValue( + new AttributeValue().withS(userPreferences.getUsername()) + )) ); + return userPreferences; + } +} diff --git a/apps/api/src/main/java/com/fridgetoplate/repository/ReviewRepository.java b/apps/api/src/main/java/com/fridgetoplate/repository/ReviewRepository.java new file mode 100644 index 00000000..e79bc137 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/repository/ReviewRepository.java @@ -0,0 +1,68 @@ +package com.fridgetoplate.repository; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBScanExpression; +import com.amazonaws.services.dynamodbv2.datamodeling.PaginatedScanList; +import com.fridgetoplate.model.Review; +import org.springframework.stereotype.Repository; + +@Repository +public class ReviewRepository { + + @Autowired + private DynamoDBMapper dynamoDBMapper; + + public Review save(Review review){ + dynamoDBMapper.save(review); + return review; + } + + public List getReviewsByRecipeId(String id) { + List reviews = new ArrayList<>(); + + PaginatedScanList scanResult = dynamoDBMapper.scan(Review.class, new DynamoDBScanExpression()); + + for (Review review : scanResult) { + + if (review.getRecipeId().equals(id)) { + reviews.add(review); + } + } + + return reviews; + } + + public List getReviewsByUsername(String username) { + List reviews = new ArrayList<>(); + + PaginatedScanList scanResult = dynamoDBMapper.scan(Review.class, new DynamoDBScanExpression()); + + for (Review review : scanResult) { + + if (review.getUsername().equals(username)) { + reviews.add(review); + } + } + + return reviews; + } + + public Review getReviewByReviewId(String recipeId, String reviewId) { + return dynamoDBMapper.load(Review.class, recipeId, reviewId); + } + + public List findAll(){ + return dynamoDBMapper.scan(Review.class, new DynamoDBScanExpression()); + } + + public String delete(String recipeId, String reviewId){ + Review review = dynamoDBMapper.load(Review.class, recipeId, reviewId); + dynamoDBMapper.delete(review); + return "Recipe deleted successfully:: " + recipeId + reviewId; + } +} diff --git a/apps/api/src/main/java/com/fridgetoplate/service/ExternalApiService.java b/apps/api/src/main/java/com/fridgetoplate/service/ExternalApiService.java new file mode 100644 index 00000000..08d29bce --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/service/ExternalApiService.java @@ -0,0 +1,191 @@ +package com.fridgetoplate.service; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import com.fridgetoplate.frontendmodels.RecipeFrontendModel; +import com.fridgetoplate.frontendmodels.RecipePreferencesFrontendModel; +import com.fridgetoplate.interfaces.SpoonacularResponse; +import com.fridgetoplate.model.Ingredient; +import com.fridgetoplate.repository.RecipeRepository; + +import org.springframework.beans.factory.annotation.Value; + +import java.util.Arrays; + +@Service +public class ExternalApiService { + + @Autowired + private RecipeRepository recipeRepository; + + @Value("${spoonacular.baseUrl}") + private String spoonacularbaseUrl; + + @Value("${spoonacular.apiKey}") + private String spoonacularPrivateKey; + + @Autowired + private RestTemplate template = new RestTemplate(); + + List cuisineList = Arrays.asList( + "African", + "Asian", + "American", + "British", + "Cajun", + "Caribbean", + "Chinese", + "Eastern European", + "European", + "French", + "German", + "Greek", + "Indian", + "Irish", + "Italian", + "Japanese", + "Jewish", + "Korean", + "Latin American", + "Mediterranean", + "Mexican", + "Middle Eastern", + "Nordic", + "Southern", + "Spanish", + "Thai", + "Vietnamese" + ); + + Set cuisineSet = new HashSet(cuisineList); + + + + List mealTypeList = Arrays.asList( + "main course", + "side dish", + "dessert", + "appetizer", + "salad", + "bread", + "breakfast", + "soup", + "beverage", + "sauce", + "marinade", + "fingerfood", + "snack", + "drink" + ); + + Set mealTypeSet = new HashSet(mealTypeList); + + + List dietList = Arrays.asList( + "Gluten Free", + "Ketogenic", + "Vegetarian", + "Lacto-Vegetarian", + "Ovo-Vegetarian", + "Vegan", + "Pescetarian", + "Paleo", + "Primal", + "Low FODMAP", + "Whole30" + ); + + Set dietSet = new HashSet(dietList); + + public SpoonacularResponse spoonacularRecipeSearch(RecipePreferencesFrontendModel recipePreferences,List userIngredients){ + + String recipeSearchEndpoint = spoonacularbaseUrl + "/recipes/complexSearch?apiKey=" + spoonacularPrivateKey; + + if(recipePreferences.getPrepTime() != null) + recipeSearchEndpoint += "&maxReadyTime=" + recipePreferences.getPrepTime().substring(0, 2); + + if(userIngredients != null && userIngredients.size() != 0){ + + String ingredientsListString = "&includeIngredients="; + for(int i = 0; i < userIngredients.size(); i++){ + + ingredientsListString += userIngredients.get(i).getName().toLowerCase(); + + if(i < userIngredients.size() - 1) + ingredientsListString += ","; + } + + recipeSearchEndpoint += ingredientsListString; + } + + + if(recipePreferences.getKeywords() != null && recipePreferences.getKeywords().length != 0){ + String[] keywordsList = recipePreferences.getKeywords(); + + String dietPreferences = ""; + + String cuisinePreferences = ""; + + String mealPreference = ""; + + String titlePreference = ""; + + for(int i = 0; i < keywordsList.length; i++){ + if(cuisineSet.contains(keywordsList[i].strip())){ + if(cuisinePreferences.length() != 0) + cuisinePreferences += "," + keywordsList[i].strip(); + else + cuisinePreferences += keywordsList[i].strip(); + continue; + } + + if(mealTypeSet.contains(keywordsList[i].strip().toLowerCase())){ + if(mealPreference.length() != 0) + mealPreference += "," + keywordsList[i].strip(); + else + mealPreference += keywordsList[i].strip(); + continue; + } + + if(dietSet.contains(keywordsList[i].strip())){ + if(dietPreferences.length() != 0) + dietPreferences += "," + keywordsList[i].strip(); + else + dietPreferences += keywordsList[i].strip(); + continue; + } + + else{ + if(titlePreference.length() != 0) + titlePreference += "," + keywordsList[i].strip(); + else + titlePreference += keywordsList[i].strip(); + continue; + } + } + + if(!dietPreferences.isEmpty()) + recipeSearchEndpoint += "&diet=" + dietPreferences; + + if(!cuisinePreferences.isEmpty()) + recipeSearchEndpoint += "&cuisine=" + cuisinePreferences; + + if(!mealPreference.isEmpty()) + recipeSearchEndpoint += "&mealType=" + mealPreference; + + if(!titlePreference.isEmpty()) + recipeSearchEndpoint += "&titleMatch=" + titlePreference; + } + + recipeSearchEndpoint += "&addRecipeInformation=true&ranking=1"; + + return template.getForObject( recipeSearchEndpoint , SpoonacularResponse.class); + + } +} diff --git a/apps/api/src/main/java/com/fridgetoplate/utils/RecipeArrayConverter.java b/apps/api/src/main/java/com/fridgetoplate/utils/RecipeArrayConverter.java index d05f2ec4..58b44893 100644 --- a/apps/api/src/main/java/com/fridgetoplate/utils/RecipeArrayConverter.java +++ b/apps/api/src/main/java/com/fridgetoplate/utils/RecipeArrayConverter.java @@ -3,7 +3,7 @@ import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTypeConverter; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fridgetoplate.model.Recipe; +import com.fridgetoplate.interfaces.Recipe; public class RecipeArrayConverter implements DynamoDBTypeConverter { private static final ObjectMapper mapper = new ObjectMapper(); diff --git a/apps/api/src/main/java/com/fridgetoplate/utils/RecipePreferencesConverter.java b/apps/api/src/main/java/com/fridgetoplate/utils/RecipePreferencesConverter.java new file mode 100644 index 00000000..aca3b477 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/utils/RecipePreferencesConverter.java @@ -0,0 +1,31 @@ +package com.fridgetoplate.utils; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTypeConverter; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fridgetoplate.interfaces.RecipePreferences; + +public class RecipePreferencesConverter implements DynamoDBTypeConverter{ + private static final ObjectMapper mapper = new ObjectMapper(); + + @Override + public String convert(RecipePreferences strings) { + try { + return mapper.writeValueAsString(strings); + } catch (JsonProcessingException e) { + e.printStackTrace(); + return null; + } + } + + @Override + public RecipePreferences unconvert(String json) { + try { + return mapper.readValue(json, RecipePreferences.class); + } catch (JsonProcessingException e) { + e.printStackTrace(); + return null; + } + } + +} diff --git a/apps/api/src/main/java/com/fridgetoplate/utils/RecipeStepArrayConverter.java b/apps/api/src/main/java/com/fridgetoplate/utils/RecipeStepArrayConverter.java deleted file mode 100644 index bc3850a1..00000000 --- a/apps/api/src/main/java/com/fridgetoplate/utils/RecipeStepArrayConverter.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.fridgetoplate.utils; - -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTypeConverter; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fridgetoplate.model.RecipeStep; - -public class RecipeStepArrayConverter implements DynamoDBTypeConverter { - private static final ObjectMapper mapper = new ObjectMapper(); - - @Override - public String convert(RecipeStep[] recipes) { - try { - return mapper.writeValueAsString(recipes); - } catch (JsonProcessingException e) { - e.printStackTrace(); - return null; - } - } - - @Override - public RecipeStep[] unconvert(String json) { - try { - return mapper.readValue(json, RecipeStep[].class); - } catch (JsonProcessingException e) { - e.printStackTrace(); - return null; - } - } - - - } diff --git a/apps/api/src/main/java/com/fridgetoplate/utils/ReviewArrayConverter.java b/apps/api/src/main/java/com/fridgetoplate/utils/ReviewArrayConverter.java new file mode 100644 index 00000000..dfc117a8 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/utils/ReviewArrayConverter.java @@ -0,0 +1,32 @@ +package com.fridgetoplate.utils; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTypeConverter; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fridgetoplate.model.Review; + +public class ReviewArrayConverter implements DynamoDBTypeConverter { + private static final ObjectMapper mapper = new ObjectMapper(); + + @Override + public String convert(Review[] strings) { + try { + return mapper.writeValueAsString(strings); + } catch (JsonProcessingException e) { + e.printStackTrace(); + return null; + } + } + + @Override + public Review[] unconvert(String json) { + try { + return mapper.readValue(json, Review[].class); + } catch (JsonProcessingException e) { + e.printStackTrace(); + return null; + } + } + + +} diff --git a/apps/api/src/main/java/com/fridgetoplate/utils/SpoonacularMiscUtils.java b/apps/api/src/main/java/com/fridgetoplate/utils/SpoonacularMiscUtils.java new file mode 100644 index 00000000..ad5a17f3 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/utils/SpoonacularMiscUtils.java @@ -0,0 +1,97 @@ +package com.fridgetoplate.utils; + +import java.util.ArrayList; +import java.util.List; + +import com.fridgetoplate.model.Ingredient; + +public class SpoonacularMiscUtils { + + public String estimateRecipeDifficulty(int recipePrepTime, List ingredients){ + + if(recipePrepTime <= 0 || ingredients == null ) + return "Easy"; + + else{ + if( (recipePrepTime > 0 && recipePrepTime <= 30) && ( ingredients.size() > 0 && ingredients.size() <= 5) ) + return "Easy"; + + else if( (recipePrepTime > 30 && recipePrepTime <= 60) && ( ingredients.size() > 5 && ingredients.size() <= 10) ) + return "Medium"; + + else + return "Hard"; + } + } + + public List generateRecipeTags(String[] cuisineList, String[] dishTypeList, String[] dietList){ + if(cuisineList == null || dishTypeList == null || dietList == null){ + return new ArrayList(); + } + + else { + List tagList = new ArrayList(); + + //1. Add Cuisines + if(cuisineList.length > 2 ){ + + for(int i = 0; i < 2; i++){ + + tagList.add(cuisineList[i]); + + } + } + + else{ + + for(int i = 0; i < cuisineList.length; i++){ + + tagList.add(cuisineList[i]); + + } + } + + //2. Add Dish Types + if(dishTypeList.length > 2 ){ + + for(int i = 0; i < 2; i++){ + + tagList.add(dishTypeList[i]); + + } + } + + else{ + + for(int i = 0; i < dishTypeList.length; i++){ + + tagList.add(dishTypeList[i]); + + } + } + + //3. Add Diet List + if(dietList.length > 2 ){ + + for(int i = 0; i < 2; i++){ + + tagList.add(dietList[i]); + + } + } + + else{ + + for(int i = 0; i < dietList.length; i++){ + + tagList.add(dietList[i]); + + } + } + + return tagList; + + } + + } +} diff --git a/apps/api/src/main/java/com/fridgetoplate/utils/SpoonacularRecipeConverter.java b/apps/api/src/main/java/com/fridgetoplate/utils/SpoonacularRecipeConverter.java new file mode 100644 index 00000000..3b8833f5 --- /dev/null +++ b/apps/api/src/main/java/com/fridgetoplate/utils/SpoonacularRecipeConverter.java @@ -0,0 +1,166 @@ +package com.fridgetoplate.utils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTypeConverter; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fridgetoplate.frontendmodels.RecipeFrontendModel; +import com.fridgetoplate.interfaces.SpoonacularIngredient; +import com.fridgetoplate.interfaces.SpoonacularRecipe; +import com.fridgetoplate.interfaces.SpoonacularStep; +import com.fridgetoplate.model.Ingredient; +import com.fridgetoplate.model.RecipeModel; +import com.fridgetoplate.model.Review; + +public class SpoonacularRecipeConverter implements DynamoDBTypeConverter { + private static final ObjectMapper mapper = new ObjectMapper(); + + @Override + public SpoonacularRecipe[] convert(RecipeFrontendModel[] object) { + throw new UnsupportedOperationException("Unimplemented method 'convert'"); + } + + @Override + public RecipeFrontendModel[] unconvert(SpoonacularRecipe[] spoonacularRecipes) { + + if(spoonacularRecipes != null){ + + SpoonacularMiscUtils utils = new SpoonacularMiscUtils(); + + List convertedRecipes = new ArrayList(); + + for(int i = 0; i < spoonacularRecipes.length; i++){ + + RecipeFrontendModel newRecipe = new RecipeFrontendModel(); + + SpoonacularRecipe currentRecipe = spoonacularRecipes[i]; + + List currentIngredients = new ArrayList(); + + List currentRecipeSteps = new ArrayList(); + + newRecipe.setRecipeImage(currentRecipe.getImage()); + + newRecipe.setName(currentRecipe.getTitle()); + + newRecipe.setTags(utils.generateRecipeTags( currentRecipe.getCuisines(), currentRecipe.getDishTypes(), currentRecipe.getDiets() ) ); + + //Iterate through ingredients and add + if(currentRecipe.getAnalyzedInstructions().length != 0){ + SpoonacularStep[] recipeSteps = currentRecipe.getAnalyzedInstructions()[0].getSteps(); + + + for(int x = 0; x < recipeSteps.length; x++){ + SpoonacularStep currentStep = recipeSteps[x]; + + currentRecipeSteps.add(currentStep.getStep()); + + if(currentStep.getIngredients() != null){ + SpoonacularIngredient[] stepIngredients = currentStep.getIngredients(); + + for(int j = 0; j < stepIngredients.length; j++){ + + Ingredient newIngredient = new Ingredient(); + + newIngredient.setName(stepIngredients[j].getName()); + + newIngredient.setAmount(1); + + newIngredient.setUnit("unit"); + + if(!currentIngredients.contains(newIngredient)) + currentIngredients.add(newIngredient); + } + } + + } + + //Iterate through steps and add + newRecipe.setSteps(currentRecipeSteps); + + newRecipe.setIngredients(currentIngredients); + } + + //Create difficulty evaluation function + newRecipe.setDifficulty(utils.estimateRecipeDifficulty(currentRecipe.getCookingMinutes(), currentIngredients)); + + newRecipe.setDescription(currentRecipe.getSummary()); + + newRecipe.setMeal(currentRecipe.getDishTypes()[0]); + + newRecipe.setPrepTime(currentRecipe.getReadyInMinutes()); + + newRecipe.setServings(currentRecipe.getServings()); + + + newRecipe.setCreator("Spoonacular"); + + newRecipe.setReviews( new ArrayList() ); + + convertedRecipes.add(newRecipe); + } + + return convertedRecipes.toArray(new RecipeFrontendModel[convertedRecipes.size()]); + } + + return null; + } + + + public RecipeFrontendModel[] combineQueryResults(RecipeFrontendModel[] apiResults, RecipeFrontendModel[] dbResults){ + if(apiResults == null || dbResults == null) + return null; + + RecipeFrontendModel[] resultArray = Arrays.copyOf(apiResults, apiResults.length + dbResults.length); + + System.arraycopy(dbResults, 0, resultArray , apiResults.length, dbResults.length ); + + return resultArray; + } + + public RecipeModel[] toRecipeModelArray(RecipeFrontendModel[] apiResults){ + if(apiResults == null) + return null; + + List convertedResults = new ArrayList(); + + for(int i = 0; i < apiResults.length; i++){ + RecipeModel newRecipe = new RecipeModel(); + + newRecipe.setName(apiResults[i].getName()); + + newRecipe.setDescription(apiResults[i].getDescription()); + + newRecipe.setDifficulty(apiResults[i].getDifficulty()); + + newRecipe.setRecipeImage(apiResults[i].getRecipeImage()); + + if(apiResults[i].getMeal().isEmpty()){ + newRecipe.setMeal("snack"); + } + else{ + newRecipe.setMeal(apiResults[i].getMeal()); + } + + newRecipe.setPrepTime(apiResults[i].getPrepTime()); + + newRecipe.setServings(apiResults[i].getServings()); + + newRecipe.setCreator(apiResults[i].getCreator()); + + newRecipe.setViews(0); + + newRecipe.setSteps(apiResults[i].getSteps()); + + newRecipe.setIngredients(apiResults[i].getIngredients()); + + newRecipe.setTags(apiResults[i].getTags()); + + convertedResults.add(newRecipe); + } + + return convertedResults.toArray(new RecipeModel[convertedResults.size()]); + } +} diff --git a/apps/api/src/main/resources/application.properties b/apps/api/src/main/resources/application.properties index 5f1c700b..128e7d94 100644 --- a/apps/api/src/main/resources/application.properties +++ b/apps/api/src/main/resources/application.properties @@ -1,3 +1 @@ - - -server.port=5000 \ No newline at end of file +spring.config.import=optional:file:.env[.properties] \ No newline at end of file diff --git a/apps/api/src/test/java/com/fridgetoplate/api/MealPlanModelTest.java b/apps/api/src/test/java/com/fridgetoplate/api/MealPlanModelTest.java new file mode 100644 index 00000000..b82c1203 --- /dev/null +++ b/apps/api/src/test/java/com/fridgetoplate/api/MealPlanModelTest.java @@ -0,0 +1,40 @@ +package com.fridgetoplate.api; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper; +import com.fridgetoplate.frontendmodels.MealPlanFrontendModel; +import com.fridgetoplate.model.MealPlanModel; +import com.fridgetoplate.repository.MealPlanRepository; + +import static org.junit.jupiter.api.Assertions.assertEquals; + + + public class MealPlanModelTest { + + @Autowired + DynamoDBMapper dynamoDBMapper = Mockito.mock(DynamoDBMapper.class); + + @Test + void save_ShouldCallDynamoDBMapperSave() { + // Arrange + + MealPlanRepository repository = new MealPlanRepository(); + repository.setDynamoDBMapper(dynamoDBMapper); + MealPlanModel mealPlan = new MealPlanModel(); + + // Create a mock DynamoDBMapper (optional if not using mocking) + // DynamoDBMapper dynamoDBMapper = mock(DynamoDBMapper.class); + // repository.setDynamoDBMapper(dynamoDBMapper); + + // Act + MealPlanFrontendModel savedMealPlan = repository.save(mealPlan); + + // Assert + // Optionally, verify the save method was called + // verify(dynamoDBMapper, times(1)).save(mealPlan); + + assertEquals(mealPlan, savedMealPlan); + } +} diff --git a/apps/app-e2e/cypress/downloads/downloads.html b/apps/app-e2e/cypress/downloads/downloads.html new file mode 100644 index 00000000..edcb6195 Binary files /dev/null and b/apps/app-e2e/cypress/downloads/downloads.html differ diff --git a/apps/app-e2e/src/e2e/app.cy.ts b/apps/app-e2e/src/e2e/app.cy.ts index e333db7f..fb82014d 100644 --- a/apps/app-e2e/src/e2e/app.cy.ts +++ b/apps/app-e2e/src/e2e/app.cy.ts @@ -1,69 +1,170 @@ -import { data } from 'cypress/types/jquery'; -import { getGreeting } from '../support/app.po'; +import { it } from "mocha"; +/* eslint-disable cypress/unsafe-to-chain-command */ describe('login tests', () => { - beforeEach(() => cy.visit('/')); + beforeEach(() => cy.visit('http://localhost:4200/login')); - // it('successfully loads login', () => { - // cy.get('h1').contains('Hey, Welcome Back'); - // }); + it('should display the header', () => { + cy.get('h1').contains('Hey, Welcome Back'); + }); - // it('should prevent incorrect login attempt', () => { - // cy.fixture('user-details.json').then((userData) => { - // cy.get('input[name="username"]').type(userData.username); - // cy.get('input[name="password"]').type(userData.password); - // cy.get('button').click(); - // cy.url().contains('profile'); - // }); - // }); + it('should display a correct input form', () => { + cy.get('input[name="username"]').should('exist').should('be.visible'); + cy.get('input[name="password"]').should('exist').should('be.visible'); + cy.get('button[type="submit"]').should('exist').should('be.visible').contains('Login'); + }); - // it('should attempt login', () => { - // cy.get('input[name="username"]').type('smileyazola@gmail.com'); - // cy.get('input[name="password"]').type('randomPassword!'); - // cy.get('button').click(); - // cy.url().contains('profile'); - // }); + it('should display link to continue as guest', () => { + cy.get('a').contains('Continue as guest').click(); + }); - // it('should navigate to signup', () => { - // cy.get('button').click(); - // cy.url().contains('signup'); - // }); + it('should navigate to signup', () => { + cy.get('a').contains('Create').click(); + cy.url().should('include', 'signup'); + }); + + it('should prevent incorrect login attempt', () => { + cy.fixture('user-details.json').then((userData) => { + cy.get('input[name="username"]').type(userData[0].username); + cy.get('input[name="password"]').type(userData[0].password); + cy.get('button').contains('Login').click(); + cy.url().should('include', 'login'); + }); + }); }); describe('signup tests', () => { - beforeEach(() => cy.visit('/')); -}); -// it('successfully loads signup', () => { -// cy.get('h1').contains('Hey, Welcome Back'); -// }); - -// it('should attempt signup', () => { -// cy.fixture('user-details.json').then((userData) => { -// cy.get('input[name="username"]').type(userData.username); -// cy.get('input[name="password"]').type(userData.password); -// cy.get('button').click(); -// cy.url().contains('profile'); -// }); -// }); - -// it('should navigate to login', () => { -// cy.get('button').click(); -// cy.url().contains('login'); -// }); -// }); - -describe('profile tests', () => { - beforeEach(() => cy.visit('/')); -}); + beforeEach(() => cy.visit('http://localhost:4200/signup')); -describe('create tests', () => { - beforeEach(() => cy.visit('/')); -}); + it('should display the header', () => { + cy.get('h1').contains('Create New Account'); + }); + + it('should navigate to login page', () => { + cy.get('a').contains('Login').click(); + cy.url().should('include', 'login'); + }); -describe('generate tests', () => { - beforeEach(() => cy.visit('/')); + it('should display a correct input form', () => { + cy.get('input[name="username"]').should('exist').should('be.visible'); + cy.get('input[name="email_address"]').should('exist').should('be.visible'); + cy.get('input[name="password"]').should('exist').should('be.visible'); + cy.get('input[name="confirm_password"]').should('exist').should('be.visible'); + cy.get('button[type="submit"]').should('exist').should('be.visible').contains('Create Account'); + }); + + it('should display link to continue as guest', () => { + cy.get('a').contains('Continue as guest').click(); + }); + + it('should prevent form submission with invalid input', () => { + cy.get('input[name="username"]').type('us'); + cy.get('input[name="email_address"]').type('invalid_email'); + cy.get('input[name="password"]').type('pass'); + cy.get('input[name="confirm_password"]').type('pass'); + cy.get('button[type="submit"]').click(); + cy.url().should('include', 'signup'); + }); }); -describe('details tests', () => { - beforeEach(() => cy.visit('/')); +describe('Profile Tests', () => { + beforeEach(() => { + cy.visit('http://localhost:4200/profile'); + }); + + it('displays user profile information', () => { + cy.get('h2').should('be.visible'); + cy.get('h2').should('contain', 'John Doe'); + cy.get('p').should('contain', 'jdoe'); + }); + + it('opens edit profile modal', () => { + cy.get('#edit-profile-button').click(); + cy.get('input[name="name"]').type('Simphiwe'); + cy.get('input[name="email"]').type('simphiwe@example.com'); + cy.get('edit-modal').get('button').contains('Save Changes').click(); + }); + + it('opens the saved recipes tab', () => { + cy.get('a').contains('Saved').click(); + cy.get('div').contains('Sort') + }); + + it('opens the meal plan tab', () => { + cy.get('a').contains('Meal Plan').click(); + cy.get('div').contains('Breakfast') + }); + + it('opens the created recipes tab', () => { + cy.get('a').contains('Created').click(); + cy.get('div').contains('Sort') + }); + + it('opens edit settings modal', () => { + cy.get('#settings-button').click(); + cy.get('settings-modal').get('button').contains('Logout').click(); + }); + + it('opens notifications page', () => { + cy.get('#notifications-button').click(); + cy.url().should('include', 'notifications'); + }); }); + + describe('create tests', () => { + beforeEach(() => {cy.visit('http://localhost:4200/create');}); + + it('enters recipe details', () => { + cy.get('#name').type('Egg Salad'); + cy.get('#description').type('A delicious egg salad recipe'); + cy.get('#servings').type('4'); + cy.get('#preparation-time').type('10'); + cy.get('div').contains('Ingredients').get('button').contains('Add').click(); + // cy.get('label').contains('Ingredients').type('Crack eggs'); + cy.get('div').contains('Instructions').get('button').contains('Add').click(); + // cy.get('#instruction-0').type('Crack eggs'); + cy.get('#tag').type('Vegan'); + cy.get('button').contains('Add Tag').click(); + }); + }); + + // describe('recipe details tests', () => { + // beforeEach(() => cy.visit('http://localhost:4200/recipe/by0r-0Bo5t-D3se00')); + + // it('goes to previous page', () => { + // cy.get('#back-button').click(); + // }); + + // it('displays the recipe details', () => { + // cy.get('p').contains('Prep Time'); + // cy.get('p').contains('Ingredients'); + // cy.get('p').contains('Servings'); + // cy.get('ion-label').contains('Ingredients'); + // cy.get('ion-label').contains('Instructions'); + // cy.get('h1').contains('Reviews'); + // }); + + // it('adds a review', () => { + // cy.get('ion-textarea').click(); + // cy.get('ion-textarea').type('NOT SO GOOD STUFF!!!'); + // cy.get('ion-icon[name="star"]:nth-child(2)').click(); + // cy.get('ion-button.review-button').click(); + // }); + + // }); + + // describe('home tests', () => { + // beforeEach(() => cy.visit('/')); + + // }); + + // describe('search tests', () => { + // beforeEach(() => cy.visit('/')); + + // }); + + // describe('notifications tests', () => { + // beforeEach(() => cy.visit('/')); + + // }); +// }); \ No newline at end of file diff --git a/apps/app-e2e/src/fixtures/recipe-details.json b/apps/app-e2e/src/fixtures/recipe-details.json new file mode 100644 index 00000000..e0c3c417 --- /dev/null +++ b/apps/app-e2e/src/fixtures/recipe-details.json @@ -0,0 +1,136 @@ +[ + { + "recipeId": "1", + "name": "Spaghetti Carbonara", + "tags": ["pasta", "italian"], + "difficulty": "Medium", + "recipeImage": "https://example.com/spaghetti-carbonara.jpg", + "description": "A classic Italian pasta dish with bacon, eggs, and cheese.", + "servings": 4, + "prepTime": 20, + "meal": "Dinner", + "ingredients": [ + { "name": "spaghetti", "amount": 1, "unit": "pound" }, + { "name": "bacon", "amount": 8, "unit": "slices" }, + { "name": "eggs", "amount": 4, "unit": "" }, + { "name": "parmesan cheese", "amount": 1, "unit": "cup" }, + { "name": "black pepper", "amount": 0.5, "unit": "tsp" } + ], + "steps": [ + "Cook spaghetti according to package directions.", + "Cook bacon in a large skillet until crispy; remove from skillet and crumble.", + "In a small bowl, beat eggs and parmesan cheese together.", + "Drain spaghetti and return to skillet.", + "Add bacon to skillet and stir to combine.", + "Pour egg mixture over spaghetti and stir quickly to combine.", + "Season with black pepper and serve immediately." + ], + "creator": "italian_food_lover", + "reviews": [ + { + "reviewId": "1", + "recipeId": "1", + "username": "pasta_guru", + "rating": 5, + "description": "This is the best spaghetti carbonara recipe I've ever tried!" + }, + { + "reviewId": "2", + "recipeId": "1", + "username": "foodie_mom", + "rating": 4, + "description": "My family loved this recipe, but it was a bit too rich for my taste." + } + ] + }, + { + "recipeId": "2", + "name": "Chicken Caesar Salad", + "tags": ["salad", "chicken"], + "difficulty": "Easy", + "recipeImage": "https://example.com/chicken-caesar-salad.jpg", + "description": "A classic salad with chicken, romaine lettuce, and Caesar dressing.", + "servings": 4, + "prepTime": 15, + "meal": "Lunch", + "ingredients": [ + { "name": "romaine lettuce", "amount": 1, "unit": "head" }, + { "name": "grilled chicken breast", "amount": 2, "unit": "" }, + { "name": "caesar dressing", "amount": 0.5, "unit": "cup" }, + { "name": "croutons", "amount": 0.5, "unit": "cup" }, + { "name": "parmesan cheese", "amount": 0.5, "unit": "cup" } + ], + "steps": [ + "Wash and chop romaine lettuce.", + "Slice grilled chicken breast into strips.", + "In a large bowl, combine romaine lettuce, chicken, and croutons.", + "Drizzle Caesar dressing over salad and toss to combine.", + "Sprinkle with parmesan cheese and serve immediately." + ], + "creator": "healthy_eater", + "reviews": [ + { + "reviewId": "3", + "recipeId": "2", + "username": "salad_lover", + "rating": 4, + "description": "This is a great recipe for a quick and easy meal." + }, + { + "reviewId": "4", + "recipeId": "2", + "username": "gym_rat", + "rating": 5, + "description": "I make this salad all the time for a healthy and delicious lunch." + } + ] + }, + { + "recipeId": "3", + "name": "Blueberry Muffins", + "tags": ["breakfast", "baking"], + "difficulty": "Easy", + "recipeImage": "https://example.com/blueberry-muffins.jpg", + "description": "A classic breakfast muffin with fresh blueberries.", + "servings": 12, + "prepTime": 15, + "meal": "Breakfast", + "ingredients": [ + { "name": "all-purpose flour", "amount": 2, "unit": "cups" }, + { "name":"granulated sugar", "amount": 0.75, "unit": "cup" }, + { "name": "baking powder", "amount": 2, "unit": "tsp" }, + { "name": "salt", "amount": 0.5, "unit": "tsp" }, + { "name": "milk", "amount": 0.5, "unit": "cup" }, + { "name": "vegetable oil", "amount": 0.25, "unit": "cup" }, + { "name": "eggs", "amount": 2, "unit": "" }, + { "name": "fresh blueberries", "amount": 1.5, "unit": "cups" } + ], + "steps": [ + "Preheat oven to 400°F (200°C).", + "In a large bowl, combine flour, sugar, baking powder, and salt.", + "In a separate bowl, whisk together milk, vegetable oil, and eggs.", + "Add wet ingredients to dry ingredients and stir until just combined.", + "Fold in blueberries.", + "Spoon batter into greased muffin tins, filling each cup 2/3 full.", + "Bake for 18-20 minutes or until a toothpick inserted in the center comes out clean.", + "Cool muffins in tin for 5 minutes before removing to a wire rack to cool completely." + ], + "creator": "baking_queen", + "reviews": [ + { + "reviewId": "5", + "recipeId": "3", + "username": "blueberry_fan", + "rating": 5, + "description": "These muffins are amazing! They're so moist and flavorful." + }, + { + "reviewId": "6", + "recipeId": "3", + "username": "muffin_lover", + "rating": 4, + "description": "This is a great basic muffin recipe. I added some cinnamon for extra flavor." + } + ] + } +] diff --git a/apps/app-e2e/src/support/commands.ts b/apps/app-e2e/src/support/commands.ts index ac470cb0..7379aa83 100644 --- a/apps/app-e2e/src/support/commands.ts +++ b/apps/app-e2e/src/support/commands.ts @@ -12,6 +12,7 @@ declare namespace Cypress { // eslint-disable-next-line @typescript-eslint/no-unused-vars interface Chainable { + upload(arg0: { fileContent: any; fileName: string; mimeType: string; }): unknown; login(email: string, password: string): void; } } diff --git a/apps/app/cypress.config.ts b/apps/app/cypress.config.ts deleted file mode 100644 index 6e52e991..00000000 --- a/apps/app/cypress.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'cypress'; -import { nxE2EPreset } from '@nrwl/cypress/plugins/cypress-preset'; - -export default defineConfig({ - e2e: nxE2EPreset(__dirname, { - cypressDir: 'cypress', - }), -}); diff --git a/apps/app/cypress/e2e/app.cy.ts b/apps/app/cypress/e2e/app.cy.ts deleted file mode 100644 index cdf32391..00000000 --- a/apps/app/cypress/e2e/app.cy.ts +++ /dev/null @@ -1,70 +0,0 @@ -describe('login tests', () => { - beforeEach(() => cy.visit('/')); - - it('successfully loads login', () => { - cy.get('h1').contains('Hey, Welcome Back'); - }); - - // it('should prevent incorrect login attempt', () => { - // cy.fixture('user-details.json').then((userData) => { - // cy.get('input[name="username"]').type(userData.username); - // cy.get('input[name="password"]').type(userData.password); - // cy.get('button').click(); - // cy.url().contains('profile'); - // }); - // }); - - // it('should attempt login', () => { - // cy.get('input[name="username"]').type('smileyazola@gmail.com'); - // cy.get('input[name="password"]').type('randomPassword!'); - // cy.get('button').click(); - // cy.url().contains('profile'); - // }); - - // it('should navigate to signup', () => { - // cy.get('button').click(); - // cy.url().contains('signup'); - // }); - // }); - - // describe('signup tests', () => { - // beforeEach(() => cy.visit('/')); - - // it('successfully loads signup', () => { - // cy.get('h1').contains('Hey, Welcome Back'); - // }); - - // it('should attempt signup', () => { - // cy.fixture('user-details.json').then((userData) => { - // cy.get('input[name="username"]').type(userData.username); - // cy.get('input[name="password"]').type(userData.password); - // cy.get('button').click(); - // cy.url().contains('profile'); - // }); - // }); - - // it('should navigate to login', () => { - // cy.get('button').click(); - // cy.url().contains('login'); - // }); -}); - -describe('profile tests', () => { - beforeEach(() => cy.visit('/')); - cy.get('h1').contains('Hey, Welcome Back'); -}); - -describe('create tests', () => { - beforeEach(() => cy.visit('/')); - cy.get('h1').contains('Hey, Welcome Back'); -}); - -describe('generate tests', () => { - beforeEach(() => cy.visit('/')); - cy.get('h1').contains('Hey, Welcome Back'); -}); - -describe('details tests', () => { - beforeEach(() => cy.visit('/')); - cy.get('h1').contains('Hey, Welcome Back'); -}); diff --git a/apps/app/cypress/fixtures/example.json b/apps/app/cypress/fixtures/example.json deleted file mode 100644 index 294cbed6..00000000 --- a/apps/app/cypress/fixtures/example.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "Using fixtures to represent data", - "email": "hello@cypress.io" -} diff --git a/apps/app/cypress/support/app.po.ts b/apps/app/cypress/support/app.po.ts deleted file mode 100644 index 32934246..00000000 --- a/apps/app/cypress/support/app.po.ts +++ /dev/null @@ -1 +0,0 @@ -export const getGreeting = () => cy.get('h1'); diff --git a/apps/app/cypress/support/commands.ts b/apps/app/cypress/support/commands.ts deleted file mode 100644 index 310f1fa0..00000000 --- a/apps/app/cypress/support/commands.ts +++ /dev/null @@ -1,33 +0,0 @@ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** - -// eslint-disable-next-line @typescript-eslint/no-namespace -declare namespace Cypress { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - interface Chainable { - login(email: string, password: string): void; - } -} -// -// -- This is a parent command -- -Cypress.Commands.add('login', (email, password) => { - console.log('Custom command example: Login', email, password); -}); -// -// -- This is a child command -- -// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) diff --git a/apps/app/cypress/support/e2e.ts b/apps/app/cypress/support/e2e.ts deleted file mode 100644 index 3d469a6b..00000000 --- a/apps/app/cypress/support/e2e.ts +++ /dev/null @@ -1,17 +0,0 @@ -// *********************************************************** -// This example support/index.js is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import './commands'; diff --git a/apps/app/project.json b/apps/app/project.json index 5212913e..f5b8197d 100644 --- a/apps/app/project.json +++ b/apps/app/project.json @@ -7,7 +7,7 @@ "tags": [], "targets": { "build": { - "executor": "@angular-devkit/build-angular:browser", + "executor": "@nx/angular:webpack-browser", "outputs": ["{options.outputPath}"], "options": { "outputPath": "dist/apps/app", @@ -34,7 +34,10 @@ "input": "apps/app/src/theme/variables.scss" } ], - "scripts": [] + "scripts": [], + "customWebpackConfig": { + "path": "apps/app/webpack.config.js" + } }, "configurations": { "production": { @@ -64,7 +67,7 @@ "defaultConfiguration": "production" }, "serve": { - "executor": "@angular-devkit/build-angular:dev-server", + "executor": "@nx/angular:webpack-dev-server", "configurations": { "production": { "browserTarget": "app:build:production" diff --git a/apps/app/src/main.ts b/apps/app/src/main.ts index f702e320..43d1ee71 100644 --- a/apps/app/src/main.ts +++ b/apps/app/src/main.ts @@ -1,5 +1,19 @@ +import { enableProdMode } from "@angular/core"; import { platformBrowserDynamic } from "@angular/platform-browser-dynamic"; import { CoreModule } from '@fridge-to-plate/app/core'; +import { environment } from "@fridge-to-plate/app/environments/utils"; + +if (environment.TYPE === 'development') { + console.log(`TYPE: ${environment.TYPE}`); + console.log(`API_URL: ${environment.API_URL}`); + console.log(`COGNITO_USERPOOL_ID: ${environment.COGNITO_USERPOOL_ID}`); + console.log(`COGNITO_APP_CLIENT_ID: ${environment.COGNITO_APP_CLIENT_ID}`); +} + +if (environment.TYPE === 'production') { + enableProdMode(); +} + platformBrowserDynamic() .bootstrapModule(CoreModule) .catch((err) => console.error(err)); diff --git a/apps/app/tailwind.config.js b/apps/app/tailwind.config.js index a2d80810..0e66c5e0 100644 --- a/apps/app/tailwind.config.js +++ b/apps/app/tailwind.config.js @@ -9,8 +9,8 @@ module.exports = { ], theme: { screens: { - 'sm': '200px', - 'md': '740px', + 'sm': '320px', + 'md': '700px', 'lg': '1024px', 'xl': '1280px', '2xl': '1536px', @@ -24,6 +24,8 @@ module.exports = { 'primary-highlight': '#E26310', 'accept': '#2bc917', 'reject': '#d70b0b', + 'delete': '#B9261C', + 'delete-highlight': '#DC2626', 'subtitle': '#9D9D9D', 'input-outline': '#E6E6E6', }, diff --git a/apps/app/tsconfig.app.json b/apps/app/tsconfig.app.json index fff4a41d..974c492d 100644 --- a/apps/app/tsconfig.app.json +++ b/apps/app/tsconfig.app.json @@ -2,7 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "types": [] + "types": ["node"] }, "files": ["src/main.ts"], "include": ["src/**/*.d.ts"], diff --git a/apps/app/webpack.config.js b/apps/app/webpack.config.js new file mode 100644 index 00000000..44d31ed8 --- /dev/null +++ b/apps/app/webpack.config.js @@ -0,0 +1,5 @@ +const Dotenv = require('dotenv-webpack'); + +module.exports = { + plugins: [new Dotenv()], +} \ No newline at end of file diff --git a/cypress.config.ts b/cypress.config.ts deleted file mode 100644 index 17161e32..00000000 --- a/cypress.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from "cypress"; - -export default defineConfig({ - e2e: { - setupNodeEvents(on, config) { - // implement node event listeners here - }, - }, -}); diff --git a/cypress/e2e/test-spec.cy.ts b/cypress/e2e/test-spec.cy.ts deleted file mode 100644 index 322992ce..00000000 --- a/cypress/e2e/test-spec.cy.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('template spec', () => { - it('passes', () => { - cy.visit('https://example.cypress.io') - }) -}) \ No newline at end of file diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json deleted file mode 100644 index 02e42543..00000000 --- a/cypress/fixtures/example.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "Using fixtures to represent data", - "email": "hello@cypress.io", - "body": "Fixtures are a great way to mock data for responses to routes" -} diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts deleted file mode 100644 index 698b01a4..00000000 --- a/cypress/support/commands.ts +++ /dev/null @@ -1,37 +0,0 @@ -/// -// *********************************************** -// This example commands.ts shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('login', (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) -// -// declare global { -// namespace Cypress { -// interface Chainable { -// login(email: string, password: string): Chainable -// drag(subject: string, options?: Partial): Chainable -// dismiss(subject: string, options?: Partial): Chainable -// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable -// } -// } -// } \ No newline at end of file diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts deleted file mode 100644 index f80f74f8..00000000 --- a/cypress/support/e2e.ts +++ /dev/null @@ -1,20 +0,0 @@ -// *********************************************************** -// This example support/e2e.ts is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import './commands' - -// Alternatively you can use CommonJS syntax: -// require('./commands') \ No newline at end of file diff --git a/libs/app/auth/data-access/src/auth.state.ts b/libs/app/auth/data-access/src/auth.state.ts new file mode 100644 index 00000000..30164124 --- /dev/null +++ b/libs/app/auth/data-access/src/auth.state.ts @@ -0,0 +1,277 @@ +import { Injectable } from '@angular/core'; +import { Action, Selector, State, StateContext, Store } from '@ngxs/store'; +import { + ChangePassword, + Login, + Logout, + SignUp, + Forgot, + NewPassword, +} from '@fridge-to-plate/app/auth/utils'; +import { ShowError } from '@fridge-to-plate/app/error/utils'; +import { + AuthenticationDetails, + CognitoUserAttribute, + CognitoUserPool, + CognitoUser, +} from 'amazon-cognito-identity-js'; +import { + CreateNewProfile, + IProfile, + ResetProfile, + RetrieveProfile, +} from '@fridge-to-plate/app/profile/utils'; +import { Navigate } from '@ngxs/router-plugin'; +import { environment } from '@fridge-to-plate/app/environments/utils'; +import { + IPreferences, + CreateNewPreferences, + ResetPreferences, + RetrievePreferences, +} from '@fridge-to-plate/app/preferences/utils'; +import { CognitoIdentityServiceProvider } from 'aws-sdk'; +import { ConfirmForgotPasswordRequest } from 'aws-sdk/clients/cognitoidentityserviceprovider'; +import { + AddRecommendation, + ClearRecommend, + GetUpdatedRecommendation, + IRecipePreferences, +} from '@fridge-to-plate/app/recommend/utils'; + +interface formDataInterface { + 'custom:username': string; + email: string; + [key: string]: string; +} + +export interface AuthStateModel { + accessToken: string; +} + +@State({ + name: 'auth', + defaults: { + accessToken: 'none', + }, +}) +@Injectable() +export class AuthState { + private poolData = { + UserPoolId: environment.COGNITO_USERPOOL_ID, + ClientId: environment.COGNITO_APP_CLIENT_ID, + }; + + constructor(private store: Store) {} + + @Selector() + getAccessToken(state: AuthStateModel) { + return state.accessToken; + } + + @Action(SignUp) + async signUp( + { setState }: StateContext, + { username, email, password }: SignUp + ) { + const userPool = new CognitoUserPool(this.poolData); + + const attributeList = []; + + const formData: formDataInterface = { + 'custom:username': username, + email: email, + }; + + for (const key in formData) { + const attrData = { + Name: key, + Value: formData[key], + }; + const attribute = new CognitoUserAttribute(attrData); + attributeList.push(attribute); + } + + await userPool.signUp(username, password, attributeList, [], (err, result) => { + if (err) { + this.store.dispatch(new ShowError(err.message || JSON.stringify(err))); + setState({ + accessToken: 'none', + }); + return; + } + + setState({ + accessToken: + result?.user.getSignInUserSession()?.getAccessToken().getJwtToken() || + 'none', + }); + + const profile: IProfile = { + displayName: username, + username: username, + profilePic: + 'https://www.pngitem.com/pimgs/m/24-248366_profile-clipart-generic-user-generic-profile-picture-gender.png', + email: email, + ingredients: [], + savedRecipes: [], + createdRecipes: [], + currMealPlan: null, + }; + + const preference: IPreferences = { + username: username, + darkMode: false, + recommendNotif: false, + viewsNotif: false, + reviewNotif: false, + }; + + const defaultRecommend: IRecipePreferences = { + difficulty: '', + meal: '', + keywords: [], + prepTime: '', + rating: '', + servings: '', + }; + + this.store.dispatch(new CreateNewProfile(profile)); + + this.store.dispatch(new CreateNewPreferences(preference)); + + this.store.dispatch(new AddRecommendation(defaultRecommend)); + + this.store.dispatch(new Navigate(['/home'])); + }); + } + + @Action(Login) + async login( + { setState }: StateContext, + { username, password }: Login + ) { + const userPool = new CognitoUserPool(this.poolData); + + const authenticationDetails = new AuthenticationDetails({ + Username: username, + Password: password, + }); + + const userData = { Username: username, Pool: userPool }; + const cognitoUser = new CognitoUser(userData); + await cognitoUser.authenticateUser(authenticationDetails, { + onSuccess: (result) => { + setState({ + accessToken: result.getAccessToken().getJwtToken(), + }); + this.store.dispatch(new RetrieveProfile(username)); + this.store.dispatch(new RetrievePreferences(username)); + this.store.dispatch(new GetUpdatedRecommendation(username)); + this.store.dispatch(new Navigate(['/home'])); + }, + onFailure: (err) => { + this.store.dispatch(new ShowError(err.message || JSON.stringify(err))); + setState({ + accessToken: 'none', + }); + }, + }); + } + + @Action(Logout) + logout({ setState }: StateContext) { + setState({ + accessToken: 'none', + }); + + this.store.dispatch(new ResetProfile()); + this.store.dispatch(new ResetPreferences()); + this.store.dispatch(new ClearRecommend()); + localStorage.clear(); + this.store.dispatch(new Navigate(['/login'])); + } + + @Action(ChangePassword) + ChangePassword( + { getState }: StateContext, + { oldPassword, newPassword }: ChangePassword + ) { + if (getState().accessToken != 'none') { + const accessToken = getState().accessToken; + const params = { + PreviousPassword: oldPassword, + ProposedPassword: newPassword, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + AccessToken: accessToken!, + }; + + const region = 'eu-west-3'; + const cognito = new CognitoIdentityServiceProvider({ region }); + + cognito.changePassword(params, (err, data) => { + if (err) { + console.error('Password change error:', err); + } else { + console.log('Password changed successfully.'); + } + }); + } + } + + @Action(Forgot) + forgot({ setState }: StateContext, { username }: Forgot) { + const params = { + ClientId: environment.COGNITO_APP_CLIENT_ID, // Your client id here + Username: username, + }; + + try { + // Initiate the password reset + const cognitoIdentityServiceProvider = new CognitoIdentityServiceProvider( + { region: 'eu-west-3' } + ); + cognitoIdentityServiceProvider.forgotPassword(params).promise(); + + // Password reset initiated successfully, redirect the user to a confirmation page + localStorage.setItem('username', username); + this.store.dispatch(new Navigate(['/forgot/verification'])); + // (You can handle the confirmation page in your frontend application) + console.log('Password reset initiated successfully'); + } catch (error) { + // Handle errors + this.store.dispatch(new ShowError('Could Not Send Verification Code')); + console.error('Error initiating password reset:', error); + } + } + + @Action(NewPassword) + NewPassword( + { getState }: StateContext, + { verificationCode, newPassword }: NewPassword + ) { + const region = 'eu-west-3'; + + const cognitoIdentityServiceProvider = new CognitoIdentityServiceProvider({ + region, + }); + + const cognito = new CognitoIdentityServiceProvider({ region: 'eu-west-3' }); + + try { + const params: ConfirmForgotPasswordRequest = { + ClientId: environment.COGNITO_APP_CLIENT_ID, + ConfirmationCode: verificationCode, + Username: localStorage.getItem('username') || '', + Password: newPassword, + }; + + cognito.confirmForgotPassword(params).promise(); + console.log('Password successfully reset.'); + this.store.dispatch(new Navigate(['/forgot/confirm'])); + } catch (err) { + console.error('Error confirming forgotten password:', err); + this.store.dispatch(new ShowError('Could Not Confirm New Password')); + throw err; + } + } +} diff --git a/libs/app/auth/data-access/src/index.ts b/libs/app/auth/data-access/src/index.ts index c5c262c9..285ee6b9 100644 --- a/libs/app/auth/data-access/src/index.ts +++ b/libs/app/auth/data-access/src/index.ts @@ -1 +1 @@ -export * from './auth.module'; +export * from './auth.state'; \ No newline at end of file diff --git a/libs/app/create/data-access/.eslintrc.json b/libs/app/auth/utils/.eslintrc.json similarity index 100% rename from libs/app/create/data-access/.eslintrc.json rename to libs/app/auth/utils/.eslintrc.json diff --git a/libs/app/auth/utils/README.md b/libs/app/auth/utils/README.md new file mode 100644 index 00000000..edae5cb2 --- /dev/null +++ b/libs/app/auth/utils/README.md @@ -0,0 +1,7 @@ +# app-auth-utils + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-auth-utils` to execute the unit tests. diff --git a/libs/app/create/data-access/jest.config.ts b/libs/app/auth/utils/jest.config.ts similarity index 84% rename from libs/app/create/data-access/jest.config.ts rename to libs/app/auth/utils/jest.config.ts index 8864a4ad..2c3562f3 100644 --- a/libs/app/create/data-access/jest.config.ts +++ b/libs/app/auth/utils/jest.config.ts @@ -1,9 +1,9 @@ /* eslint-disable */ export default { - displayName: 'app-create-data-access', + displayName: 'app-auth-utils', preset: '../../../../jest.preset.js', setupFilesAfterEnv: ['/src/test-setup.ts'], - coverageDirectory: '../../../../coverage/libs/app/create/data-access', + coverageDirectory: '../../../../coverage/libs/app/auth/utils', transform: { '^.+\\.(ts|mjs|js|html)$': [ 'jest-preset-angular', diff --git a/libs/app/auth/utils/project.json b/libs/app/auth/utils/project.json new file mode 100644 index 00000000..c569011f --- /dev/null +++ b/libs/app/auth/utils/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-auth-utils", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/auth/utils/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/auth/utils/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/auth/utils/**/*.ts", + "libs/app/auth/utils/**/*.html" + ] + } + } + } +} diff --git a/libs/app/auth/utils/src/auth.actions.ts b/libs/app/auth/utils/src/auth.actions.ts new file mode 100644 index 00000000..1b76624e --- /dev/null +++ b/libs/app/auth/utils/src/auth.actions.ts @@ -0,0 +1,28 @@ +export class SignUp { + static readonly type = '[Auth] SignUp'; + constructor(public readonly username: string, public readonly password: string, public readonly email: string) {} +} + +export class Login { + static readonly type = '[Auth] Login'; + constructor(public readonly username: string, public readonly password: string) {} +} + +export class Logout { + static readonly type = '[Auth] Logout'; +} + +export class Forgot { + static readonly type = '[Auth] Forgot'; + constructor(public readonly username: string) {} +} + +export class ChangePassword { + static readonly type = '[Auth] ChangePassword'; + constructor(public readonly oldPassword: string, public readonly newPassword: string) {} +} + +export class NewPassword { + static readonly type = '[Auth] NewPassword'; + constructor(public readonly verificationCode: string, public readonly newPassword: string) {} +} \ No newline at end of file diff --git a/libs/app/auth/utils/src/index.ts b/libs/app/auth/utils/src/index.ts new file mode 100644 index 00000000..d3e39ccd --- /dev/null +++ b/libs/app/auth/utils/src/index.ts @@ -0,0 +1 @@ +export * from './auth.actions'; diff --git a/libs/app/create/data-access/src/test-setup.ts b/libs/app/auth/utils/src/test-setup.ts similarity index 100% rename from libs/app/create/data-access/src/test-setup.ts rename to libs/app/auth/utils/src/test-setup.ts diff --git a/libs/app/create/data-access/tsconfig.json b/libs/app/auth/utils/tsconfig.json similarity index 100% rename from libs/app/create/data-access/tsconfig.json rename to libs/app/auth/utils/tsconfig.json diff --git a/libs/app/create/data-access/tsconfig.lib.json b/libs/app/auth/utils/tsconfig.lib.json similarity index 100% rename from libs/app/create/data-access/tsconfig.lib.json rename to libs/app/auth/utils/tsconfig.lib.json diff --git a/libs/app/create/data-access/tsconfig.spec.json b/libs/app/auth/utils/tsconfig.spec.json similarity index 100% rename from libs/app/create/data-access/tsconfig.spec.json rename to libs/app/auth/utils/tsconfig.spec.json diff --git a/libs/app/core/src/core.module.ts b/libs/app/core/src/core.module.ts index b1521e0d..9309b57c 100644 --- a/libs/app/core/src/core.module.ts +++ b/libs/app/core/src/core.module.ts @@ -4,20 +4,34 @@ import { CoreShell } from './core.shell'; import { CoreRouting } from './core.routing'; import { IonicModule, IonicRouteStrategy } from '@ionic/angular'; import { RouteReuseStrategy } from '@angular/router'; -import { LoginModule } from '@fridge-to-plate/app/login/feature'; import { TabbedComponent } from './tabbed-component/tabbed-component'; import { NzStepsModule } from 'ng-zorro-antd/steps'; import { NzFormModule } from 'ng-zorro-antd/form'; import { NzIconModule } from 'ng-zorro-antd/icon'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { HttpClientModule } from '@angular/common/http'; - +import { NgxsLoggerPluginModule } from '@ngxs/logger-plugin'; +import { NgxsReduxDevtoolsPluginModule } from '@ngxs/devtools-plugin'; +import { NgxsModule } from '@ngxs/store'; +import { ErrorState } from '@fridge-to-plate/app/error/data-access'; +import { NgxsRouterPluginModule } from '@ngxs/router-plugin'; +import { AuthState } from '@fridge-to-plate/app/auth/data-access'; +import { UndoState } from '@fridge-to-plate/app/undo/data-access'; +import { environment } from '@fridge-to-plate/app/environments/utils'; +import { NavigationBarModule } from '@fridge-to-plate/app/navigation/feature'; +import { LOCAL_STORAGE_ENGINE, NgxsStoragePluginModule } from '@ngxs/storage-plugin'; +import { ProfileState } from '@fridge-to-plate/app/profile/data-access'; +import { PreferencesState } from '@fridge-to-plate/app/preferences/data-access'; +import { RecommendState } from '@fridge-to-plate/app/recommend/data-access'; +import { NgxsActionsExecutingModule } from '@ngxs-labs/actions-executing' @NgModule({ - declarations: [CoreShell, TabbedComponent], + declarations: [ + CoreShell, + TabbedComponent, + ], imports: [ BrowserModule, - LoginModule, CoreRouting, ReactiveFormsModule, IonicModule.forRoot(), @@ -26,6 +40,33 @@ import { HttpClientModule } from '@angular/common/http'; NzIconModule, HttpClientModule, FormsModule, + NavigationBarModule, + NgxsLoggerPluginModule.forRoot({ + collapsed: false, + disabled: environment.TYPE == 'production', + }), + NgxsReduxDevtoolsPluginModule.forRoot({ + disabled: environment.TYPE == 'production', + }), + NgxsModule.forRoot([AuthState, ErrorState, UndoState]), + NgxsStoragePluginModule.forRoot({ + key: [ + { + key: ProfileState, + engine: LOCAL_STORAGE_ENGINE + }, + { + key: PreferencesState, + engine: LOCAL_STORAGE_ENGINE + }, + { + key: RecommendState, + engine: LOCAL_STORAGE_ENGINE + }, + ] + }), + NgxsRouterPluginModule.forRoot(), + NgxsActionsExecutingModule.forRoot(), ], providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }], bootstrap: [CoreShell], diff --git a/libs/app/core/src/core.routing.ts b/libs/app/core/src/core.routing.ts index ece336a0..690f46e4 100644 --- a/libs/app/core/src/core.routing.ts +++ b/libs/app/core/src/core.routing.ts @@ -1,15 +1,21 @@ import { NgModule } from '@angular/core'; import { PreloadAllModules, RouterModule, Routes } from '@angular/router'; +import { RouteGuardService } from './guards/route-guard.service'; const routes: Routes = [ { path: '', pathMatch: 'full', - redirectTo: 'login', + redirectTo: 'home', }, { path: 'recommend', loadChildren: () => import('@fridge-to-plate/app/recommend/feature').then((m) => m.RecommendModule), + canActivate: [RouteGuardService] + }, + { + path: 'search', + loadChildren: () => import('@fridge-to-plate/app/explore/feature').then((m) => m.ExploreModule), }, { path: 'login', @@ -19,24 +25,47 @@ const routes: Routes = [ path: 'signup', loadChildren: () => import('@fridge-to-plate/app/signup/feature').then((m) => m.SignupModule), }, + { + path: 'forgot', + loadChildren: () => import('@fridge-to-plate/app/forgot/feature').then((m) => m.ForgotModule), + }, { path: 'profile', loadChildren: () => import('@fridge-to-plate/app/profile/feature').then((m) => m.ProfileModule), + canActivate: [RouteGuardService] }, { path: 'create', loadChildren: () => import('@fridge-to-plate/app/create/feature').then((m) => m.CreateModule), + canActivate: [RouteGuardService] }, { path: 'recipe', - loadChildren: () => import('@fridge-to-plate/app/recipe/feature').then((m) => m.RecipeModule) - } + loadChildren: () => import('@fridge-to-plate/app/recipe/feature').then((m) => m.RecipeModule), + }, + { + path: 'edit-recipe', + loadChildren : () => import('@fridge-to-plate/app/edit-recipe/feature').then((m) => m.EditRecipeModule), + canActivate: [RouteGuardService] + }, + { + path: 'unauthorised', + loadChildren : () => import('@fridge-to-plate/app/unauthorised/feature').then((m) => m.UnauthorisedModule), + }, + { + path: 'home', + loadChildren: () => import('@fridge-to-plate/app/home/feature').then((m) => m.HomeModule), + }, + { + path: '**', + redirectTo: 'home' + }, ]; @NgModule({ - imports: [ - RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules }), - ], - exports: [RouterModule], + imports: [ + RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules }), + ], + exports: [RouterModule], }) export class CoreRouting {} diff --git a/libs/app/core/src/core.shell.html b/libs/app/core/src/core.shell.html index e46579e1..7dcd3b5b 100644 --- a/libs/app/core/src/core.shell.html +++ b/libs/app/core/src/core.shell.html @@ -1,4 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/libs/app/core/src/core.shell.ts b/libs/app/core/src/core.shell.ts index 45acdf71..2a589996 100644 --- a/libs/app/core/src/core.shell.ts +++ b/libs/app/core/src/core.shell.ts @@ -1,3 +1,5 @@ +/* eslint-disable @angular-eslint/component-selector */ +/* eslint-disable @angular-eslint/component-class-suffix */ import { Component } from "@angular/core"; @Component({ diff --git a/libs/app/core/src/directives/clicked-outside.directive.ts b/libs/app/core/src/directives/clicked-outside.directive.ts new file mode 100644 index 00000000..fb364aae --- /dev/null +++ b/libs/app/core/src/directives/clicked-outside.directive.ts @@ -0,0 +1,21 @@ +import { Directive, ElementRef, EventEmitter, HostListener, Output } from '@angular/core'; + +@Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector + selector: '[appClickedOutside]', +}) +export class ClickedOutsideDirective { + + constructor(private el: ElementRef) {} + + @Output() clickedOutsideFunc = new EventEmitter(); + + @HostListener('document:click', ['$event.target']) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public onClick(target: any) { + const clickedInside = this.el.nativeElement.contains(target); + if (!clickedInside) { + this.clickedOutsideFunc.emit(); + } + } +} diff --git a/libs/app/core/src/guards/route-guard.service.spec.ts b/libs/app/core/src/guards/route-guard.service.spec.ts new file mode 100644 index 00000000..d1683fc6 --- /dev/null +++ b/libs/app/core/src/guards/route-guard.service.spec.ts @@ -0,0 +1,92 @@ +import { TestBed } from '@angular/core/testing'; +import { RouteGuardService } from './route-guard.service'; +import { Router } from '@angular/router'; +import { IProfile } from '@fridge-to-plate/app/profile/utils'; +import { NgxsModule, State } from '@ngxs/store'; +import { Injectable } from '@angular/core'; + +describe('RouteGuardService', () => { + + const testProfile: IProfile = { + displayName: "John Doe", + username: "jdoe", + email: "jdoe@gmail.com", + savedRecipes: [], + ingredients: [], + profilePic: "image-url", + createdRecipes: [], + currMealPlan: null, + }; + + let service: RouteGuardService; + let navigateSpy: jest.SpyInstance; + let router: Router; + + it('should be created', () => { + + @State({ + name: 'profile', + defaults: { + profile: null + } + }) + @Injectable() + class MockProfileState {} + + TestBed.configureTestingModule({ + imports: [NgxsModule.forRoot([MockProfileState])] + }); + + service = TestBed.inject(RouteGuardService); + + expect(service).toBeTruthy(); + }); + + it('should remain be routed to login (No Login)', () => { + + @State({ + name: 'profile', + defaults: { + profile: null + } + }) + @Injectable() + class MockProfileState {} + + TestBed.configureTestingModule({ + imports: [NgxsModule.forRoot([MockProfileState])] + }); + + service = TestBed.inject(RouteGuardService); + router = TestBed.inject(Router); + + navigateSpy = jest.spyOn(router, 'parseUrl'); + + expect(service).toBeTruthy(); + service.canActivate(); + expect(navigateSpy).toHaveBeenCalledWith('unauthorised'); + }); + + it('should navigate to all routes (Login provided)', () => { + + @State({ + name: 'profile', + defaults: { + profile: testProfile + } + }) + @Injectable() + class MockProfileState {} + + TestBed.configureTestingModule({ + imports: [NgxsModule.forRoot([MockProfileState])] + }); + + service = TestBed.inject(RouteGuardService); + router = TestBed.inject(Router); + + expect(service).toBeTruthy(); + expect(service.canActivate()).toBe(true); + }); + +}); diff --git a/libs/app/core/src/guards/route-guard.service.ts b/libs/app/core/src/guards/route-guard.service.ts new file mode 100644 index 00000000..160c02e6 --- /dev/null +++ b/libs/app/core/src/guards/route-guard.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import { CanActivate, Router } from '@angular/router'; +import { ProfileState } from '@fridge-to-plate/app/profile/data-access'; +import { IProfile } from '@fridge-to-plate/app/profile/utils'; +import { Select } from '@ngxs/store'; +import { Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class RouteGuardService implements CanActivate { + + @Select(ProfileState.getProfile) profile$ !: Observable; + profile: IProfile | null; + + constructor( + private router: Router + ) { + this.profile$.subscribe(stateProfile => this.profile = stateProfile); + } + + canActivate(){ + if(this.profile){ + return true; + } else { + return this.router.parseUrl('unauthorised'); + } + } +} diff --git a/libs/app/core/src/tabbed-component/tabbed-component.ts b/libs/app/core/src/tabbed-component/tabbed-component.ts index beea9a4c..7a392388 100644 --- a/libs/app/core/src/tabbed-component/tabbed-component.ts +++ b/libs/app/core/src/tabbed-component/tabbed-component.ts @@ -1,6 +1,7 @@ import { Component } from '@angular/core'; @Component({ + // eslint-disable-next-line @angular-eslint/component-selector selector: 'app-tabbed-component', templateUrl: './tabbed-component.html', styleUrls: ['./tabbed-component.scss'] diff --git a/libs/app/create/data-access/README.md b/libs/app/create/data-access/README.md deleted file mode 100644 index 3c44a986..00000000 --- a/libs/app/create/data-access/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# app-create-data-access - -This library was generated with [Nx](https://nx.dev). - -## Running unit tests - -Run `nx test app-create-data-access` to execute the unit tests. diff --git a/libs/app/create/data-access/src/api/create.api.ts b/libs/app/create/data-access/src/api/create.api.ts deleted file mode 100644 index bf644e99..00000000 --- a/libs/app/create/data-access/src/api/create.api.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { IRecipe } from '@fridge-to-plate/app/recipe/utils'; -import { Observable } from 'rxjs'; -import { IIngredient } from '@fridge-to-plate/app/ingredient/utils'; - -@Injectable( - { - providedIn: 'root', -} -) -export class CreateAPI { - constructor( private http: HttpClient){ } - - createNewRecipe(recipe: IRecipe): Observable { - const url = 'http://localhost:5000/recipes/create'; - - return this.http.post(url, recipe); - } - - createNewIngredient(ingredient : IIngredient): Observable { - const url = 'http://localhost:5000/ingredients/create'; - - return this.http.post(url, ingredient); - } - - createNewMultipleIngredients(ingredient : IIngredient[]): Observable { - const url = 'http://localhost:5000/ingredients/create-multi'; - - return this.http.post(url, ingredient); - } - - -} \ No newline at end of file diff --git a/libs/app/create/data-access/src/index.ts b/libs/app/create/data-access/src/index.ts deleted file mode 100644 index 055581a3..00000000 --- a/libs/app/create/data-access/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './create.module' -export * from './api/create.api' diff --git a/libs/app/create/feature/src/create.module.ts b/libs/app/create/feature/src/create.module.ts index 2278ea4a..cf2da6d3 100644 --- a/libs/app/create/feature/src/create.module.ts +++ b/libs/app/create/feature/src/create.module.ts @@ -5,6 +5,7 @@ import { CreateRouting } from './create.routing' import { CreatePagComponent } from './create.page'; import { IonicModule } from '@ionic/angular'; import { NavigationBarModule } from '@fridge-to-plate/app/navigation/feature' +import { ProfileDataAccessModule } from '@fridge-to-plate/app/profile/data-access'; @NgModule({ imports: [ @@ -14,6 +15,7 @@ import { NavigationBarModule } from '@fridge-to-plate/app/navigation/feature' CreateRouting, IonicModule, NavigationBarModule, + ProfileDataAccessModule ], declarations: [CreatePagComponent], }) diff --git a/libs/app/create/feature/src/create.page.html b/libs/app/create/feature/src/create.page.html index 91251dec..8c1d3e6b 100644 --- a/libs/app/create/feature/src/create.page.html +++ b/libs/app/create/feature/src/create.page.html @@ -1,152 +1,307 @@ -
- - -
- + +
+
+ Recipe Image -
+ /> + + - - -
- -
- +
- - +
+ + +
+ + +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +
+ + + +
+
- + +
+ +
+ + + + + +
+
+
-
-
-
- +
+
+ +
+ +
+ +
+ + +
+ +
+ - + class="w-full bg-gray-200 border border-gray-400 p-2 rounded-2xl"> + +
+ +
+ +
+
+
- + +
-
+
-
- - +
+ {{ i+1 }}. + +
+
- -
- - -
- -
- - -
- -
- -
+ + + +
+ + +
+ - Paleo +
+
+
    +
  • + {{ tag }} +
  • +
+
+
- -
- + +
+
- -
\ No newline at end of file +
diff --git a/libs/app/create/feature/src/create.page.spec.ts b/libs/app/create/feature/src/create.page.spec.ts index 83b4de87..8e5bd75d 100644 --- a/libs/app/create/feature/src/create.page.spec.ts +++ b/libs/app/create/feature/src/create.page.spec.ts @@ -1,45 +1,30 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormArray, FormBuilder, FormControl, FormGroup, ReactiveFormsModule, UntypedFormBuilder} from '@angular/forms'; +import { FormArray, FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; import { CreatePagComponent } from './create.page'; import { IonicModule } from '@ionic/angular'; import {HttpClientModule } from '@angular/common/http'; import { NavigationBarModule } from '@fridge-to-plate/app/navigation/feature' import { IIngredient } from '@fridge-to-plate/app/ingredient/utils'; -import { IRecipe, IRecipeStep } from '@fridge-to-plate/app/recipe/utils'; -import { NEVER, of } from "rxjs"; -import { CreateAPI } from '../../data-access/src/api/create.api'; +import { IRecipe } from '@fridge-to-plate/app/recipe/utils'; +import { BehaviorSubject, take } from "rxjs"; +import { Injectable } from '@angular/core'; +import { NgxsModule, State, Store } from '@ngxs/store'; +import { IProfile } from '@fridge-to-plate/app/profile/utils'; +import { CreateRecipe } from '@fridge-to-plate/app/recipe/utils'; +import { ShowError } from '@fridge-to-plate/app/error/utils'; + + +@State({ + name: 'create', + defaults: { + recipe: null, + } +}) -describe('CreatePage', () => { - let component: CreatePagComponent; - let fixture: ComponentFixture; +@Injectable() +class MockCreateState {} - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [ CreatePagComponent ], - imports: [ - ReactiveFormsModule, - IonicModule, - HttpClientModule, - NavigationBarModule - ], - providers: [ FormBuilder ] - }) - .compileComponents(); - }); - beforeEach(() => { - fixture = TestBed.createComponent(CreatePagComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should add a new instruction control to the form', () => { - const initialLength = component.instructionControls.length; - component.addInstruction(); - const newLength = component.instructionControls.length; - expect(newLength).toBe(initialLength + 1); - }); -}); describe('CreatePagComponent', () => { let createPage: CreatePagComponent; @@ -52,7 +37,8 @@ describe('CreatePagComponent', () => { ReactiveFormsModule, IonicModule, HttpClientModule, - NavigationBarModule + NavigationBarModule, + NgxsModule.forRoot([MockCreateState]) ], providers: [ FormBuilder ] }) @@ -65,18 +51,6 @@ describe('CreatePagComponent', () => { fixture.detectChanges(); }); - it('should create a recipe form with the correct fields', () => { - createPage.createForm(); - - expect(createPage.recipeForm.contains('name')).toBe(true); - expect(createPage.recipeForm.contains('description')).toBe(true); - expect(createPage.recipeForm.contains('servings')).toBe(true); - expect(createPage.recipeForm.contains('preparationTime')).toBe(true); - expect(createPage.recipeForm.contains('ingredients')).toBe(true); - expect(createPage.recipeForm.contains('instructions')).toBe(true); - expect(createPage.recipeForm.contains('dietaryPlans')).toBe(true); - }); - it('should set the name, description, servings, and preparationTime fields as required', () => { createPage.createForm(); @@ -101,15 +75,6 @@ describe('CreatePagComponent', () => { expect(instructionsArray?.value).toEqual([]); }); - it('should create an empty array for the dietaryPlans field', () => { - createPage.createForm(); - - const dietaryPlansArray = createPage.recipeForm.get('dietaryPlans'); - - expect(dietaryPlansArray?.value).toEqual([]); - }); - - it('should add a new ingredient control to the form', () => { const initialLength = createPage.ingredientControls.length; createPage.addIngredient(); @@ -139,40 +104,60 @@ describe('CreatePagComponent', () => { ); + it('get instruction steps as String[]', () => { + const formArray = new FormArray([ + new FormControl('Step 1'), + new FormControl('Step 2'), + new FormControl('Step 3'), + ]); + + // create a new recipe form using the form array + const recipeForm = new FormGroup({ + instructions: formArray, + }); + + createPage.recipeForm = recipeForm; + + const instructions = createPage.getInstructions(); + + expect(instructions[0]).toBe('Step 1'); + expect(instructions[1]).toBe('Step 2'); + expect(instructions[2]).toBe('Step 3'); + }) + + it('should remove an instruction control from the form', () => { + + const formArray = new FormArray([ + new FormControl('Step 1'), + new FormControl('Step 2'), + new FormControl('Step 3'), + ]); + + // create a new recipe form using the form array + const recipeForm = new FormGroup({ + instructions: formArray, + }); + + createPage.recipeForm = recipeForm; + const initialLength = createPage.instructionControls.length; - if(initialLength == 0) { - expect(initialLength).toBe(0) - return - } createPage.removeInstruction(0); const newLength = createPage.instructionControls.length; expect(newLength).toBe(initialLength - 1); - } - ); - - it('should add a new dietary plan to the form', () => { - const initialLength = createPage.dietaryPlans.length; - createPage.toggleDietaryPlan('Vegan'); - const newLength = createPage.dietaryPlans.length; - expect(newLength).toBe(initialLength + 1); - } - ); - - it('should remove a dietary plan from the form', () => { - const initialLength = createPage.dietaryPlans.length; - createPage.toggleDietaryPlan('Vegan'); - createPage.toggleDietaryPlan('Vegan'); - const newLength = createPage.dietaryPlans.length; - expect(newLength).toBe(initialLength); + expect(createPage.getInstructions()).toEqual(['Step 2', 'Step 3']) } ); }); -describe('toggleDietaryPlan', () => { + +describe('Testing Tags', () => { let component: CreatePagComponent; let fb: FormBuilder; + let fixture: ComponentFixture; + let store: Store; + let dispatchSpy: jest.SpyInstance; beforeEach(() => { TestBed.configureTestingModule({ @@ -181,87 +166,160 @@ describe('toggleDietaryPlan', () => { imports: [ ReactiveFormsModule, HttpClientModule, - NavigationBarModule + NavigationBarModule, + NgxsModule.forRoot([MockCreateState]) ] }); - component = TestBed.createComponent(CreatePagComponent).componentInstance; + + fixture = TestBed.createComponent(CreatePagComponent); + component = fixture.componentInstance; fb = TestBed.inject(FormBuilder); + fixture.detectChanges(); + store = TestBed.inject(Store); + dispatchSpy = jest.spyOn(store, 'dispatch'); + component.recipeForm = fb.group({ - dietaryPlans: fb.array([]), + tag: ['', Validators.required], }); }); - it('should remove the dietary plan if it is already selected', () => { + it("Should selet a meal type successfully", () => { + const mealType = 'Breakfast'; + component.selectedMeal = mealType; + jest.spyOn(component, 'toggleMeal'); - const plan = 'Vegetarian'; - const dietaryPlans = component.recipeForm.get('dietaryPlans') as FormArray; - dietaryPlans.push(fb.control(plan)); + // Act + component.toggleMeal(mealType); - component.toggleDietaryPlan(plan); + // Assert + expect(component.selectedMeal).toBe(mealType) + expect(component.toggleMeal).toBeCalledWith(mealType) + }) - expect(dietaryPlans.length).toBe(0); - }); + it("The selected meals should change when the user changes", () => { + + const mealType = 'Lunch'; + component.selectedMeal = mealType; - it('should add the dietary plan if it is not selected', () => { + // Act + const mealType2 = 'Dinner'; + // Act + component.toggleMeal(mealType2); - const plan = 'Vegan'; - const dietaryPlans = component.recipeForm.get('dietaryPlans') as FormArray; + // Assert + expect(component.selectedMeal).toBe(mealType2); + expect(component.selectedMeal).not.toBe(mealType); + + }) - component.toggleDietaryPlan(plan); + it("Should selet a difficulty successfully", () => { + const difficulty = 'Easy'; + component.difficulty = difficulty; + jest.spyOn(component, 'toggleDifficulty'); - expect(dietaryPlans.length).toBe(1); - expect(dietaryPlans.value).toContain(plan); + // Act + component.toggleDifficulty(difficulty); + + // Assert + expect(component.difficulty).toBe(difficulty); + expect(component.toggleDifficulty).toBeCalledWith(difficulty); + expect(component.difficulty).toBe(difficulty); + expect(component.toggleDifficulty).toBeCalledWith(difficulty); + }) + + it("The selected difficulty should change when the user changes", () => { + + const difficulty1 = 'Easy'; + component.difficulty = difficulty1; + + // Act + const difficulty2 = 'Medium'; + // Act + component.toggleDifficulty(difficulty2); + + // Assert + expect(component.difficulty).toBe(difficulty2); + expect(component.difficulty).not.toBe(difficulty1); + + }) + + it('should not add a tag if tagValue is empty', () => { + // Arrange + component.recipeForm.get('tag')?.setValue(''); + const size = component.tags.length; + // Act + component.addTag(); + + // Assert + expect(component.tags.length).toBe(size); + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('Please enter valid tag')); }); + it('should not add a duplicate tags', () => { + // Arrange + component.recipeForm.get('tag')?.setValue('Tag 1'); + const testTags = ['Tag 1']; + component.tags = testTags; + const size = component.tags.length; - it('Returns an array of instruction controls', () => { - const formArray = new FormArray([ - new FormControl('Step 1'), - new FormControl('Step 2'), - new FormControl('Step 3'), - ]); + // Act + component.addTag(); - // create a new recipe form using the form array - const recipeForm = new FormGroup({ - instructions: formArray, - }); + // Assert + expect(component.tags.length).toBe(size); + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('No duplicates: Tag already selected')); + expect(component.tags).toEqual(testTags); - component.recipeForm = recipeForm; + }); + + it('Should not add if tags is already at size three(3)', () => { + // Arrange + component.recipeForm.get('tag')?.setValue('Tag 4'); + const testTags = ['Tag 1', 'Tag 2', 'Tag 3']; + component.tags = testTags; + + // Act + component.addTag(); - const controls = component.instructionControls; - expect(controls.length).toBe(3); - expect(controls[0] instanceof FormControl).toBe(true); - expect(controls[1] instanceof FormControl).toBe(true); - expect(controls[2] instanceof FormControl).toBe(true); - + // Assert + expect(component.tags.length).toBe(3); + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('Only a maximum of three tags')); + expect(component.tags).toEqual(testTags); }) - it('Returns an array of ingredients controls', () => { - const formArray = new FormArray([ - new FormControl('Mango'), - new FormControl('Potato'), - new FormControl('Banana'), - ]); + it('should add a tag if tagValue is not empty', () => { + // Arrange + component.recipeForm.get('tag')?.setValue('Tag 1'); - // create a new recipe form using the form array - const recipeForm = new FormGroup({ - ingredients: formArray, - }); + // Act + component.addTag(); + + const testTagsOutput = ['Tag 1']; + // Assert + expect(component.tags.length).toBe(1); + expect(component.tags).toEqual(testTagsOutput); + }); + + it("Should delete a meal tag successfully", () => { + + const testTags = ['Tag 1', 'Tag 2', 'Tag 3']; + component.tags = testTags; - component.recipeForm = recipeForm; + component.deleteTag(0); - const controls = component.ingredientControls; - expect(controls.length).toBe(3); - expect(controls[0] instanceof FormControl).toBe(true); - expect(controls[1] instanceof FormControl).toBe(true); - expect(controls[2] instanceof FormControl).toBe(true); - + const testTagsOutput = ['Tag 2', 'Tag 3']; + // Assert + expect(component.tags.length).toBe(2); + expect(component.tags).toEqual(testTagsOutput); }) + }); -describe('Testing Tags', () => { + +describe('Ingredients storing, deleting and returning', () => { let component: CreatePagComponent; - let fb: FormBuilder; + let fixture: ComponentFixture; + let formBuilder: FormBuilder; beforeEach(() => { TestBed.configureTestingModule({ @@ -270,140 +328,192 @@ describe('Testing Tags', () => { imports: [ ReactiveFormsModule, HttpClientModule, - NavigationBarModule + NavigationBarModule, + NgxsModule.forRoot([MockCreateState]) ] }); + fixture = TestBed.createComponent(CreatePagComponent); + component = fixture.componentInstance; + formBuilder = TestBed.inject(FormBuilder); + + fixture.detectChanges(); - component = TestBed.createComponent(CreatePagComponent).componentInstance; - fb = TestBed.inject(FormBuilder); - component.recipeForm = fb.group({ - dietaryPlans: fb.array([]), }); - }); - it('It should not add null values to tags array', () => { + it('Gets an array of IIngredient objects ', () => { + // create a mock form array with some form controls + const formArray = new FormArray([ + new FormControl({ + name: 'Mango', + amount: 100, + unit: 'g' + }), + new FormControl({ + name: 'Potato', + amount: 1, + unit: 'kg' + }), + new FormControl({ + name: 'Banana', + amount: 300, + unit: 'g' + }), + new FormControl({ + name: 'Salad', + amount: 100, + unit: 'g' + }), + new FormControl({ + name: 'Onion', + amount: 1, + unit: 'whole' + }), + ]); - const plan1 = 'Vegetarian'; - const plan2 = 'Vegan'; - const plan3 = null; - const dietaryPlans = component.recipeForm.get('dietaryPlans') as FormArray; - dietaryPlans.push(fb.control(plan1)); - dietaryPlans.push(fb.control(plan2)); - dietaryPlans.push(fb.control(plan3)); - const tags = []; + // create a new recipe form using the form array + const recipeForm = new FormGroup({ + ingredients: formArray, + }); - for (let index = 0; index < dietaryPlans.length; index++) { - if(dietaryPlans.controls[index].value !== null){ - tags.push(dietaryPlans.controls[index].value) - } - - } + component.recipeForm = recipeForm; + + const ingredients : IIngredient[] = component.getIngredients(); + + + // assert that the instructions array was created correctly + expect(ingredients[0]).toEqual({ name: "Mango", amount: 100, unit: "g" }); + expect(ingredients[1]).toEqual({ name: "Potato", amount: 1, unit: "kg" }) + expect(ingredients[2]).toEqual({ name: "Banana", amount: 300, unit: "g" }) + expect(ingredients[3]).toEqual({ name: "Salad", amount: 100, unit: "g" }) + expect(ingredients[4]).toEqual({ name: "Onion", amount: 1, unit: "whole" }) + + }) + + it('should remove the ingredient at the specified index', () => { + + component.recipeForm = formBuilder.group({ + ingredients: formBuilder.array([ + formBuilder.group({ + name: ['Ingredient 1', Validators.required], + amount: [1, Validators.required], + scale: ['kg', Validators.required], + }), + formBuilder.group({ + name: ['Ingredient 2', Validators.required], + amount: [2, Validators.required], + scale: ['g', Validators.required], + }), + ]), + }); - expect(tags.length).toBe(2); - expect(tags).not.toContain(null); - expect(tags).toContain("Vegetarian"); - expect(tags).toContain("Vegan"); - + // Arrange + const indexToRemove = 1; + const initialIngredientsCount = component.ingredientControls.length; + + // Act + component.removeIngredient(indexToRemove); + + // Assert + const finalIngredientsCount = component.ingredientControls.length; + expect(finalIngredientsCount).toBe(initialIngredientsCount - 1); + expect(component.ingredientControls[1]).toBeUndefined(); }); -}); + }); -describe('Ingredients storing and return', () => { - let component: CreatePagComponent; - let apiService: jest.Mocked; - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [ CreatePagComponent ], - providers: [FormBuilder], - imports: [ - ReactiveFormsModule, - HttpClientModule, - NavigationBarModule - ] + describe("Testing placeholder texts for Amount", () => { + + let component: CreatePagComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ CreatePagComponent ], + providers: [FormBuilder], + imports: [ + ReactiveFormsModule, + HttpClientModule, + NavigationBarModule, + NgxsModule.forRoot([MockCreateState]) + ] + }); + fixture = TestBed.createComponent(CreatePagComponent); + component = fixture.componentInstance; + fixture.detectChanges(); }); - component = TestBed.createComponent(CreatePagComponent).componentInstance; - apiService = TestBed.inject(CreateAPI) as jest.Mocked; + it('should return "e.g 10" when window width is less than 1024', () => { + // Arrange + global.innerWidth = 800; // Set the window width to a value less than 1024 + + // Act + const placeholderText = component.getAmountPlaceholderText(); + + // Assert + expect(placeholderText).toBe('e.g 10'); }); - it('Create Ingredients', () => { - // Mock data - const expectData = { - ingredientId : "123", - name: "Chicken Falaty" - } + it('should return "Amount" when window width is greater than or equal to 1024', () => { + // Arrange + global.innerWidth = 1200; // Set the window width to a value greater than or equal to 1024 - // Mocking the service - const mockIngredients : IIngredient[] = []; - mockIngredients.push(expectData) + // Act + const placeholderText = component.getAmountPlaceholderText(); - const mockApi = { - createNewMultipleIngredients: jest.fn().mockReturnValue(mockIngredients), - }; + // Assert + expect(placeholderText).toBe('Amount'); + }); + }) - const testObject = { api: mockApi }; - const returnIngredients = testObject.api.createNewMultipleIngredients() + describe("Testing placeholder texts for Unit", () => { - expect(returnIngredients[0]).toEqual(expectData); + let component: CreatePagComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ CreatePagComponent ], + providers: [FormBuilder], + imports: [ + ReactiveFormsModule, + HttpClientModule, + NavigationBarModule, + NgxsModule.forRoot([MockCreateState]) + ] + }); + fixture = TestBed.createComponent(CreatePagComponent); + component = fixture.componentInstance; + fixture.detectChanges(); }); - it("should call the createNewMultipleIngredients method on the ApiService object with the correct arguments", async () => { - // Create a mock array of IIngredient objects - const ingredients: IIngredient[] = [ - { name: "Ingredient 1" }, - { name: "Ingredient 2" }, - ]; - - // Set up the mock response from the createNewMultipleIngredients method - const response: IIngredient[] = [ - { ingredientId: "1", name: "Ingredient 1" }, - { ingredientId: "2", name: "Ingredient 2" }, - ]; - apiService.createNewMultipleIngredients = jest.fn().mockResolvedValue(response); - - // Call the createIngredients method and wait for it to resolve - apiService.createNewMultipleIngredients(ingredients); - - // Verify that the createNewMultipleIngredients method was called on the ApiService object with the correct arguments - expect(apiService.createNewMultipleIngredients).toHaveBeenCalledWith(ingredients); + it('should return "e.g 10" when window width is less than 1024', () => { + // Arrange + global.innerWidth = 800; // Set the window width to a value less than 1024 + + // Act + const placeholderText = component.getUnitPlaceholderText(); + + // Assert + expect(placeholderText).toBe('e.g L'); }); - - - // Assuming the ApiService is using `rxjs` Observables - - it("should resolve the promise with the correct response", async () => { - // Create a mock array of IIngredient objects - const ingredients: IIngredient[] = [ - { name: "Ingredient 1" }, - { name: "Ingredient 2" }, - ]; - - // Set up the mock response from the createNewMultipleIngredients method - const response: IIngredient[] = [ - { ingredientId: "1", name: "Ingredient 1" }, - { ingredientId: "2", name: "Ingredient 2" }, - ]; - - // Mock the createNewMultipleIngredients method to return an observable - apiService.createNewMultipleIngredients = jest.fn().mockReturnValue(of(response)); - - // Call the createIngredients method and wait for it to resolve - const result = await component.createIngredients(ingredients); - - // Verify that the promise resolves to the correct response - expect(result).toEqual(response); - }); - - - }); + it('should return "Amount" when window width is greater than or equal to 1024', () => { + // Arrange + global.innerWidth = 1200; // Set the window width to a value greater than or equal to 1024 + // Act + const placeholderText = component.getUnitPlaceholderText(); + + // Assert + expect(placeholderText).toBe('Unit'); + }); + }) + + describe("Image upload", () => { - describe("Testing Recipe Creation", () => { let component: CreatePagComponent; - let fb: FormBuilder; - let apiService: jest.Mocked let fixture: ComponentFixture; + beforeEach(() => { TestBed.configureTestingModule({ declarations: [ CreatePagComponent ], @@ -411,189 +521,462 @@ describe('Ingredients storing and return', () => { imports: [ ReactiveFormsModule, HttpClientModule, - NavigationBarModule + NavigationBarModule, + NgxsModule.forRoot([MockCreateState]) ] }); fixture = TestBed.createComponent(CreatePagComponent); component = fixture.componentInstance; - apiService = TestBed.inject(CreateAPI) as jest.Mocked; - fb = TestBed.inject(FormBuilder); - component.recipeForm = fb.group({ - dietaryPlans: fb.array([]), + fixture.detectChanges(); + }); + + it('should update the imageUrl when a file is selected', () => { + // Arrange + const file = new File(['sample content'], 'sample.jpg', { type: 'image/jpeg' }); + const event = { target: { files: [file] } }; + const existingImage = component.imageUrl; + + const readAsDataURLStringSpy = jest.spyOn(FileReader.prototype, 'readAsDataURL'); + + // Act + component.onFileChanged(event); + + // Assert + expect(readAsDataURLStringSpy).toHaveBeenCalledWith(file); + + const reader = new FileReader(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + reader.addEventListener("load", function(event) { + expect(component.imageUrl).toBe(file.name); + expect(component.imageUrl).not.toBe(existingImage); }); }); - it('creates an array of IRecipeStep objects', () => { - // create a mock form array with some form controls - const formArray = new FormArray([ + }); + + describe('isFormValid()', () =>{ + + let component: CreatePagComponent; + let fixture: ComponentFixture; + let store: Store; + let dispatchSpy: jest.SpyInstance; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ CreatePagComponent ], + providers: [FormBuilder], + imports: [ + ReactiveFormsModule, + HttpClientModule, + NavigationBarModule, + NgxsModule.forRoot([MockCreateState]) + ] + }); + fixture = TestBed.createComponent(CreatePagComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + store = TestBed.inject(Store); + dispatchSpy = jest.spyOn(store, 'dispatch'); + }); + + it('At least one ingredient should present', () => { + + const formBuilder: FormBuilder = new FormBuilder(); + + const formGroup: FormGroup = formBuilder.group({ + name: ['Name', Validators.required], + description: ['Description', Validators.required], + servings: [1, Validators.required], + preparationTime: [1, Validators.required], + ingredients: new FormArray([]) + }) + + component.recipeForm = formGroup; + component.isFormValid(); + + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('No Ingredients')); + + }) + + it('At least one instruction step should present', () => { + + const formBuilder: FormBuilder = new FormBuilder(); + const ingredientsFormArray = new FormArray([ + new FormControl({ + name: 'Mango', + amount: 100, + unit: 'g' + })]) + + const formGroup: FormGroup = formBuilder.group({ + name: ['Name', Validators.required], + description: ['Description', Validators.required], + servings: [1, Validators.required], + preparationTime: [1, Validators.required], + ingredients: ingredientsFormArray, + instructions: new FormArray([]) + }) + + component.recipeForm = formGroup; + component.isFormValid(); + + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('No Instructions')); + + }) + + it('Tags if empty', () => { + const formBuilder: FormBuilder = new FormBuilder(); + const ingredientsFormArray = new FormArray([ + new FormControl({ + name: 'Mango', + amount: 100, + unit: 'g' + })]) + const instructionsFormArray = new FormArray([ + new FormControl('Step 1') + ]); + + const formGroup: FormGroup = formBuilder.group({ + name: ['Name', Validators.required], + description: ['Description', Validators.required], + servings: [1, Validators.required], + preparationTime: [1, Validators.required], + ingredients: ingredientsFormArray, + instructions: instructionsFormArray + }) + + component.recipeForm = formGroup; + component.isFormValid(); + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('No Tags')); + }); + + + it('Meal Selection', () => { + const formBuilder: FormBuilder = new FormBuilder(); + const ingredientsFormArray = new FormArray([ + new FormControl({ + name: 'Mango', + amount: 100, + unit: 'g' + })]) + const instructionsFormArray = new FormArray([ + new FormControl('Step 1') + ]); + + const formGroup: FormGroup = formBuilder.group({ + ingredients: ingredientsFormArray, + instructions: instructionsFormArray + }) + + component.tags = ['Asian'] + + component.recipeForm = formGroup; + component.isFormValid(); + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('Please select a meal')); + + }) + + + it('Truthy Profile', () => { + const formBuilder: FormBuilder = new FormBuilder(); + const ingredientsFormArray = new FormArray([ + new FormControl({ + name: 'Mango', + amount: 100, + unit: 'g' + })]) + const instructionsFormArray = new FormArray([ + new FormControl('Step 1') + ]); + + const formGroup: FormGroup = formBuilder.group({ + name: ['Name', Validators.required], + description: ['Description', Validators.required], + servings: [1, Validators.required], + preparationTime: [1, Validators.required], + ingredients: ingredientsFormArray, + instructions: instructionsFormArray + }) + + component.recipeForm = formGroup; + component.tags = ['Asian']; + component.selectedMeal = 'Breakfast'; + component.isFormValid(); + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('Please login to create a recipe')); + }) + + + it('Form fields validation', () => { + const formBuilder: FormBuilder = new FormBuilder(); + const ingredientsFormArray = new FormArray([ + new FormControl({ + name: 'Mango', + amount: 100, + unit: 'g' + })]) + const instructionsFormArray = new FormArray([ + new FormControl('Step 1') + ]); + + const formGroup: FormGroup = formBuilder.group({ + name: ['', Validators.required], + description: ['', Validators.required], + servings: [1, Validators.required], + preparationTime: [1, Validators.required], + ingredients: ingredientsFormArray, + instructions: instructionsFormArray, + tags: formBuilder.array([]), + }) + + const testProfile: IProfile = { + displayName: "John Doe", + username: "jdoe", + email: "jdoe@gmail.com", + savedRecipes: [], + ingredients: [], + profilePic: "image-url", + createdRecipes: [], + currMealPlan: null, + }; + + component.recipeForm = formGroup; + component.selectedMeal = 'Breakfast'; + component.tags = ['Asian']; + component.profile = testProfile; + component.isFormValid(); + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('Incomplete Form. Please fill out every field.')) + }) + + + it('The form should test valid', () => { + const formBuilder: FormBuilder = new FormBuilder(); + + const stepsFormArray = new FormArray([ new FormControl('Step 1'), new FormControl('Step 2'), new FormControl('Step 3'), ]); - - // create a mock form group with the form array - const formGroup = new FormGroup({ - instructions: formArray, + + const ingredientsFormArray = new FormArray([ + new FormControl({ + name: 'Mango', + amount: 100, + unit: 'g' + }), + new FormControl({ + name: 'Potato', + amount: 1, + unit: 'kg' + }), + new FormControl({ + name: 'Banana', + amount: 300, + unit: 'g' + }), + new FormControl({ + name: 'Salad', + amount: 100, + unit: 'g' + }), + new FormControl({ + name: 'Onion', + amount: 1, + unit: 'whole' + }), + ]); + + const testProfile: IProfile = { + displayName: "John Doe", + username: "jdoe", + email: "jdoe@gmail.com", + savedRecipes: [], + ingredients: [], + profilePic: "image-url", + createdRecipes: [], + currMealPlan: null, + }; + + const formGroup: FormGroup = formBuilder.group({ + name: ['Name', Validators.required], + description: ['Description', Validators.required], + servings: [1, Validators.required], + preparationTime: [1, Validators.required], + ingredients: ingredientsFormArray, + instructions: stepsFormArray, + tags: formBuilder.array([]), }); - - // create a new instance of the RecipeComponent - - // assign the mock form group to the component's recipeForm property + + component.selectedMeal = 'Breakfast'; + component.tags = ['Asian']; + component.profile = testProfile + component.recipeForm = formGroup; - - // call the createInstructions method and check the result - ; - - const instructions: IRecipeStep[] = []; - for (let index = 0; index < component.instructionControls.length; index++) { - instructions.push({ - instructionHeading: 'N/A', - instructionBody: component.instructionControls[index].value, - }); - } + expect(component.isFormValid()).toBe(true); + }) - }); - - it('creates an array of IIngredient objects', () => { - // create a mock form array with some form controls - const formArray = new FormArray([ - new FormControl('Mango'), - new FormControl('Potato'), - new FormControl('Banana'), - new FormControl('Salad'), - new FormControl('Onion'), - ]); - - // create a new recipe form using the form array - const recipeForm = new FormGroup({ - ingredients: formArray, - }); - - component.recipeForm = recipeForm; - - const controls = component.ingredientControls; - - const ingredients : IIngredient[] = []; - for (let index = 0; index < controls.length; index++) { - ingredients.push({ - name: controls[index].value, - }); + }) + + describe("Testing Recipe Creation", () => { + let component: CreatePagComponent; + let fb: FormBuilder; + let fixture: ComponentFixture; + let store: Store; + let dispatchSpy: jest.SpyInstance; + + const testProfile: IProfile = { + displayName: "John Doe", + username: "jdoe", + email: "jdoe@gmail.com", + savedRecipes: [], + ingredients: [], + profilePic: "image-url", + createdRecipes: [], + currMealPlan: null, + }; + + @State({ + name: 'profile', + defaults: { + profile: testProfile } - - // assert that the instructions array was created correctly - expect(ingredients[0]).toEqual({ name: "Mango",}); - expect(ingredients[1]).toEqual({ name: "Potato" }) - expect(ingredients[2]).toEqual({ name: "Banana" }) - expect(ingredients[3]).toEqual({ name: "Salad" }) - expect(ingredients[4]).toEqual({ name: "Onion" }) - }) + @Injectable() + class MockProfileState {} + - it("should reject the promise if the response is falsy", async () => { - // Create a mock array of IIngredient objects - const ingredients: IIngredient[] = [ - { name: "Ingredient 1" }, - { name: "Ingredient 2" }, - ]; - - // Set up the mock response from the createNewMultipleIngredients method as falsy (empty array) - let response!: IIngredient[]; - jest.spyOn(apiService, 'createNewMultipleIngredients').mockReturnValue(of(response)); - - // Call the createIngredients method - const result = component.createIngredients(ingredients); - expect(result).toBeTruthy(); - // Await the promise rejection and verify the expected result - await expect(result).rejects.toEqual(response); - - // Verify that the createNewMultipleIngredients method was called with the correct arguments - expect(apiService.createNewMultipleIngredients).toHaveBeenCalledWith(ingredients); + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ CreatePagComponent ], + providers: [FormBuilder, Store], + imports: [ + ReactiveFormsModule, + HttpClientModule, + NavigationBarModule, + NgxsModule.forRoot([MockCreateState, MockProfileState]) + ] + }); + fixture = TestBed.createComponent(CreatePagComponent); + component = fixture.componentInstance; + fb = TestBed.inject(FormBuilder); + store = TestBed.inject(Store); + dispatchSpy = jest.spyOn(store, 'dispatch'); }); - it("should resolve the promise if the response is truthy", async () => { - // Create a mock array of IIngredient objects - const ingredients: IIngredient[] = [ - { name: "Ingredient 1" }, - { name: "Ingredient 2" }, - ]; - - // Set up the mock response from the createNewMultipleIngredients method as truthy - const response: IIngredient[] = [ - { ingredientId: "1", name: "Ingredient 1" }, - { ingredientId: "2", name: "Ingredient 2" }, - ]; - jest.spyOn(apiService, 'createNewMultipleIngredients').mockReturnValue(of(response)); - - // Call the createIngredients method - const result = component.createIngredients(ingredients); - expect(result).toBeTruthy(); - // Await the promise resolution and verify the expected result - await expect(result).resolves.toEqual(response); - - // Verify that the createNewMultipleIngredients method was called with the correct arguments - expect(apiService.createNewMultipleIngredients).toHaveBeenCalledWith(ingredients); - }); + it("Should render the user's username", () => { + component.profile$.subscribe((profile: IProfile) => { + expect(component.profile.username).toBe(profile.username); + }) + }) + + it('Should dispatch CreateRecipe Action', async () => { + + jest.spyOn(component, 'isFormValid'); + const profileDataSubject = new BehaviorSubject(undefined); - it('should create the recipe', async () => { + component.profile$.pipe(take(1)).subscribe((profile: IProfile) => { + component.profile = profile; + profileDataSubject.next(profile); // Update the BehaviorSubject with the profileData + }); + + // Mock the recipe data const recipe: IRecipe = { name: "Mock Recipe", recipeImage: "https://example.com/image.jpg", - ingredients: [ + description: "Amazing meal for a family", + meal: "Dinner", + creator: profileDataSubject.value?.username ?? '', + ingredients: [ {name: 'ingredient1' , amount : 5, unit : 'L'}, + {name: 'ingredient2' , amount : 3, unit : 'g'} ], - instructions: [ - { - instructionHeading: "N/A", - instructionBody: "Mock instructions", - }, + steps: [ + "Mock instructions", ], - rating: 0, - difficulty: "easy", + difficulty: "Easy", prepTime: 30, - numberOfServings: 4, + servings: 4, tags: ["mock", "recipe"], }; - - const response: IRecipe = { - recipeId: "1", - ...recipe, // Copy the properties from the recipe object - }; - - jest.spyOn(component, "createIngredients").mockResolvedValue([]); - jest.spyOn(apiService, "createNewRecipe").mockReturnValue(of(response)); - + component.imageUrl = recipe.recipeImage // Mock the values and controls used in createRecipe component.recipeForm = fb.group({ name: fb.control(recipe.name), - servings: fb.control(recipe.numberOfServings), + description: fb.control(recipe.description), + difficulty: fb.control(recipe.difficulty), + servings: fb.control(recipe.servings), preparationTime: fb.control(recipe.prepTime), - ingredients: fb.array(recipe.ingredients.map(ingredient => fb.control(ingredient.name))), - instructions: fb.array(recipe.instructions.map(instruction => fb.control(instruction.instructionBody))), + ingredients: fb.array(recipe.ingredients.map(ingredient => fb.control(ingredient))), + instructions: fb.array(recipe.steps.map(instruction => fb.control(instruction))), dietaryPlans: fb.array((recipe.tags || []).map(tag => fb.control(tag))), }); - - + + component.tags = recipe.tags; + component.selectedMeal = recipe.meal; + // Call the createRecipe method component.createRecipe(); - - // Wait for the promises to resolve - await fixture.whenStable(); - - // Verify that the createNewRecipe method was called with the correct recipe argument - expect(apiService.createNewRecipe).toHaveBeenCalledWith(recipe); - // expect(apiService.createNewRecipe).toBeTruthy(); - - // Verify that the createIngredients method was called - expect(component.createIngredients).toHaveBeenCalled(); + expect(dispatchSpy).toHaveBeenCalledWith(new CreateRecipe(recipe)); + + expect(component.recipeForm.valid).toBe(true); + expect(component.isFormValid()).toBe(true) + expect(component.isFormValid).toHaveBeenCalled(); + expect(component.profile.username).toBe(profileDataSubject.value?.username ?? ''); + + }); - - }) + it('Should not create recipe if form is invalid', () => { + + jest.spyOn(component, 'isFormValid'); + + + const profileDataSubject = new BehaviorSubject(undefined); + + component.profile$.pipe(take(1)).subscribe((profile: IProfile) => { + component.profile = profile; + profileDataSubject.next(profile); // Update the BehaviorSubject with the profileData + }); + // Mock the recipe data + const recipe: IRecipe = { + name: "Mock Recipe", + recipeImage: "https://example.com/image.jpg", + description: "Amazing meal for a family", + meal: "Dinner", + creator: profileDataSubject.value?.username ?? '', + ingredients: [], + steps: [], + difficulty: "Easy", + prepTime: 30, + servings: 4, + tags: ["mock", "recipe"], + }; + + component.imageUrl = recipe.recipeImage + // Mock the values and controls used in createRecipe + component.recipeForm = fb.group({ + name: fb.control(recipe.name), + description: fb.control(recipe.description), + difficulty: fb.control(recipe.difficulty), + servings: fb.control(recipe.servings), + preparationTime: fb.control(recipe.prepTime), + ingredients: fb.array(recipe.ingredients.map(ingredient => fb.control(ingredient))), + instructions: fb.array(recipe.steps.map(instruction => fb.control(instruction))), + dietaryPlans: fb.array((recipe.tags || []).map(tag => fb.control(tag))), + }); + + component.tags = recipe.tags; + component.selectedMeal = recipe.meal; + // Call the createRecipe method + component.createRecipe(); + expect(component.isFormValid).toHaveBeenCalled(); + expect(component.isFormValid()).toBe(false) + expect(dispatchSpy).not.toHaveBeenCalledWith(new CreateRecipe(recipe)); + }) - - \ No newline at end of file + + }) diff --git a/libs/app/create/feature/src/create.page.ts b/libs/app/create/feature/src/create.page.ts index bcb97a51..53a50a5d 100644 --- a/libs/app/create/feature/src/create.page.ts +++ b/libs/app/create/feature/src/create.page.ts @@ -1,20 +1,37 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { CreateAPI } from '@fridge-to-plate/app/create/data-access'; -import { IRecipe, IRecipeStep } from '@fridge-to-plate/app/recipe/utils'; +import { IRecipe } from '@fridge-to-plate/app/recipe/utils'; import { IIngredient } from '@fridge-to-plate/app/ingredient/utils'; +import { Select, Store } from '@ngxs/store'; +import { ShowError } from '@fridge-to-plate/app/error/utils'; +import { CreateRecipe } from '@fridge-to-plate/app/recipe/utils'; +import { ProfileState } from '@fridge-to-plate/app/profile/data-access'; +import { Observable, take } from 'rxjs'; +import { IProfile, UpdateProfile } from '@fridge-to-plate/app/profile/utils'; +import { RecipeState } from '@fridge-to-plate/app/recipe/data-access'; @Component({ selector: 'fridge-to-plate-app-create', templateUrl: './create.page.html', styleUrls: ['./create.page.scss'], }) -export class CreatePagComponent { +export class CreatePagComponent implements OnInit { + + @Select(ProfileState.getProfile) profile$ !: Observable; + @Select(RecipeState.getRecipe) recipe$ !: Observable; + recipeForm!: FormGroup; - imageUrl = 'https://img.icons8.com/ios-filled/50/cooking-book--v1.png'; + imageUrl = 'https://img.freepik.com/free-photo/frying-pan-empty-with-various-spices-black-table_1220-561.jpg'; + selectedMeal!: "Breakfast" | "Lunch" | "Dinner" | "Snack" | "Dessert"; + difficulty: "Easy" | "Medium" | "Hard" = "Easy"; + tags: string[] = []; + profile !: IProfile; + + constructor(private fb: FormBuilder, private store : Store) {} - constructor(private fb: FormBuilder, private api: CreateAPI) { + ngOnInit() { this.createForm(); + this.profile$.subscribe(profile => this.profile = profile); } createForm(): void { @@ -25,19 +42,23 @@ export class CreatePagComponent { preparationTime: ['', Validators.required], ingredients: this.fb.array([]), instructions: this.fb.array([]), - dietaryPlans: this.fb.array([]), + tag: [''] }); } + get ingredientControls() { return (this.recipeForm.get('ingredients') as FormArray).controls; } - get dietaryPlans() { - return (this.recipeForm.get('dietaryPlans') as FormArray).controls; - } - addIngredient() { - this.ingredientControls.push(this.fb.control('')); + const ingredientGroup = this.fb.group({ + name: ['', Validators.required], + amount: ['', Validators.required], + unit: ['', Validators.required] + }); + + // Add the new ingredient group to the FormArray + (this.recipeForm.get('ingredients') as FormArray).push(ingredientGroup); } get instructionControls() { @@ -45,7 +66,7 @@ export class CreatePagComponent { } addInstruction(): void { - this.instructionControls.push(this.fb.control('')); + this.instructionControls.push(this.fb.control('', Validators.required)); } removeIngredient(index: number): void { @@ -56,108 +77,185 @@ export class CreatePagComponent { this.instructionControls.splice(index, 1); } - toggleDietaryPlan(plan: string): void { - const dietaryPlans = this.recipeForm.get('dietaryPlans') as FormArray; + getAmountPlaceholderText() { + if (window.innerWidth < 1024) { + return "e.g 10"; + } else { + return "Amount"; + } + } - if (dietaryPlans != null && this.isDietaryPlanSelected(plan)) { - // Remove the dietary plan if it's already selected - dietaryPlans.removeAt(dietaryPlans.value.indexOf(plan)); + getUnitPlaceholderText() { + if (window.innerWidth < 1024) { + return "e.g L"; } else { - // Add the dietary plan if it's not selected - dietaryPlans.push(this.fb.control(plan)); + return "Unit"; } } - getDietaryPlanButtonClasses(plan: string): string { - return this.isDietaryPlanSelected(plan) - ? 'bg-gray-600 text-white' - : 'bg-gray-300 text-gray-700'; + createRecipe() : void { + + // Check first if the form is completely valid + if(!this.isFormValid()) + return; + + // Ingredients array + const ingredients = this.getIngredients(); + + // Instructions array + const instructions = this.getInstructions() + + // Create Recipe details + const recipe: IRecipe = { + name: this.recipeForm.get('name')?.value, + recipeImage: this.imageUrl, + description: this.recipeForm.get('description')?.value, + meal: this.selectedMeal, + creator: this.profile.username, + ingredients: ingredients, + steps: instructions, + difficulty: this.difficulty, + prepTime: this.recipeForm.get('preparationTime')?.value as number, + servings: this.recipeForm.get('servings')?.value as number, + tags: this.tags, + }; + + this.store.dispatch( new CreateRecipe(recipe) ) + } + + + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onFileChanged(event: any) { + const file = event.target.files[0]; + const reader = new FileReader(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + reader.onload = (e: any) => { + this.imageUrl = e.target.result; + }; + + reader.readAsDataURL(file); } - isDietaryPlanSelected(plan: string): boolean { - const dietaryPlans = this.recipeForm.get('dietaryPlans')?.value; + toggleMeal(option: "Breakfast" | "Lunch" | "Dinner" | "Snack" | "Dessert") { + this.selectedMeal = option; + } - return dietaryPlans.includes(plan); + getMealPlan(option: string) { + return { + 'bg-primary': this.selectedMeal === option, + 'bg-gray-200': this.selectedMeal !== option, + 'text-white': this.selectedMeal === option, + 'text-gray-700': this.selectedMeal !== option, + 'py-2': true, + 'px-4': true, + 'rounded-md': true, + 'mr-2': true + }; } - createRecipe() : void { - // Ingredients array + toggleDifficulty(option: "Easy" | "Medium" | "Hard") { + this.difficulty = option; + } + + getDifficulty(option: string) { + return { + 'bg-primary': this.difficulty === option, + 'bg-gray-200': this.difficulty !== option, + 'text-white': this.difficulty === option, + 'text-gray-700': this.difficulty !== option, + 'py-2': true, + 'px-4': true, + 'rounded-md': true, + 'mr-2': true + }; + } + + addTag() { + const tagValue = this.recipeForm.get('tag')?.value as string; + if(!tagValue) { + this.store.dispatch( new ShowError("Please enter valid tag")) + } + else if (this.tags.length < 3) { + if(this.tags.includes(tagValue)){ + this.store.dispatch( new ShowError("No duplicates: Tag already selected")) + return; + } + this.tags.push(tagValue); + } + else { + this.store.dispatch( new ShowError("Only a maximum of three tags")) + } + // reset the form value after adding it to array + this.recipeForm.get('tag')?.reset(); + } + + deleteTag(index: number) { + this.tags.splice(index, 1); + } + + isFormValid(): boolean { + + if(!this.recipeForm.valid){ + this.store.dispatch( new ShowError("Incomplete Form. Please fill out every field.")) + return false; + } + + if(this.ingredientControls.length < 1) { + this.store.dispatch( new ShowError("No Ingredients")) + return false; + } + + if(this.instructionControls.length < 1) { + this.store.dispatch( new ShowError("No Instructions")) + return false; + } + + if(this.tags.length < 1) { + this.store.dispatch( new ShowError("No Tags")) + return false; + } + + if(!this.selectedMeal){ + this.store.dispatch( new ShowError("Please select a meal")) + return false; + } + + if(!this.profile){ + this.store.dispatch( new ShowError("Please login to create a recipe")) + return false; + } + + return true; + } + + getIngredients(): IIngredient[] { const ingredients: IIngredient[] = []; - - let tags = new Array(this.dietaryPlans.length); - this.ingredientControls.forEach((element) => { - if (element.value !== null) { - - ingredients.push({ - name: element.value + this.ingredientControls.forEach((ingredient) => { + if (ingredient) { + ingredients.push({ + name: ingredient.value.name, + amount: ingredient.value.amount, + unit: ingredient.value.unit }); } }); - // Instructions array - const instructions: IRecipeStep[] = []; + return ingredients; + } + + getInstructions() : string[] { + const instructions: string[] = []; this.instructionControls.forEach((element) => { if (element.value) { - instructions.push({ - instructionHeading: 'N/A', - instructionBody: element.value, - }); - } - }); - - // Dietary plans array - this.dietaryPlans.forEach((element) => { - if (element.value !== null) { - tags.push(element.value); + instructions.push(element.value); } }); - tags = tags.filter((value) => value !== null); - - // We store the ingredients and return ingredients - const createdIngredients = this.createIngredients(ingredients); - - // After now having stored or created the ingredients, we create the recipe. - createdIngredients.then((ingredientsArray) => { - - // The, create the recipe object - const recipe: IRecipe = { - name: this.recipeForm.get('name')?.value, - recipeImage: this.imageUrl, - ingredients: ingredientsArray, - instructions: instructions, - rating: 0, - difficulty: 'easy', - prepTime: this.recipeForm.get('preparationTime')?.value as number, - numberOfServings: this.recipeForm.get('servings')?.value as number, - tags: tags, - }; - - // Store the recipe to the database - this.api.createNewRecipe(recipe).subscribe((response) => { - if (!response) { - return response; - } - return response; - }); - - }); + return instructions; } - createIngredients(ingredients: IIngredient[]) : Promise { - const recipe = new Promise((resolve, reject) => { - this.api - .createNewMultipleIngredients(ingredients) - .subscribe((response) => { - if (!response) { - reject(response); - } - resolve(response); - }); - }) - - return recipe; - } } diff --git a/libs/app/edit-recipe/data-access/.eslintrc.json b/libs/app/edit-recipe/data-access/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/edit-recipe/data-access/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/edit-recipe/data-access/README.md b/libs/app/edit-recipe/data-access/README.md new file mode 100644 index 00000000..42ebf70a --- /dev/null +++ b/libs/app/edit-recipe/data-access/README.md @@ -0,0 +1,7 @@ +# app-edit-recipe-data-access + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-edit-recipe-data-access` to execute the unit tests. diff --git a/libs/app/edit-recipe/data-access/jest.config.ts b/libs/app/edit-recipe/data-access/jest.config.ts new file mode 100644 index 00000000..7a4835a9 --- /dev/null +++ b/libs/app/edit-recipe/data-access/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-edit-recipe-data-access', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/edit-recipe/data-access', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/edit-recipe/data-access/project.json b/libs/app/edit-recipe/data-access/project.json new file mode 100644 index 00000000..07740929 --- /dev/null +++ b/libs/app/edit-recipe/data-access/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-edit-recipe-data-access", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/edit-recipe/data-access/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/edit-recipe/data-access/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/edit-recipe/data-access/**/*.ts", + "libs/app/edit-recipe/data-access/**/*.html" + ] + } + } + } +} diff --git a/libs/app/create/data-access/src/create.module.ts b/libs/app/edit-recipe/data-access/src/edit-recipe.module.ts similarity index 60% rename from libs/app/create/data-access/src/create.module.ts rename to libs/app/edit-recipe/data-access/src/edit-recipe.module.ts index 6898e203..3b4b2de9 100644 --- a/libs/app/create/data-access/src/create.module.ts +++ b/libs/app/edit-recipe/data-access/src/edit-recipe.module.ts @@ -1,8 +1,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { CreateAPI } from './api/create.api'; @NgModule({ imports: [CommonModule], }) -export class AppCreateDataAccessModule {} +export class EditRecipeDataAccessModule {} diff --git a/libs/app/edit-recipe/data-access/src/edit-recipe.state.ts b/libs/app/edit-recipe/data-access/src/edit-recipe.state.ts new file mode 100644 index 00000000..8099acb0 --- /dev/null +++ b/libs/app/edit-recipe/data-access/src/edit-recipe.state.ts @@ -0,0 +1,48 @@ +import { Injectable } from "@angular/core"; +import { RecipeAPI } from "@fridge-to-plate/app/recipe/data-access"; +import { IRecipe } from "@fridge-to-plate/app/recipe/utils"; +import { Selector, Store, State, StateContext, Action} from "@ngxs/store"; +import { LoadRecipe } from '@fridge-to-plate/app/edit-recipe/utils' +import { ShowError } from "@fridge-to-plate/app/error/utils"; +import { Navigate } from "@ngxs/router-plugin"; + +export interface EditRecipeStateModel { + editRecipe: IRecipe | null; +} + + + @State({ + name: 'editRecipe', + defaults: { + editRecipe: null, + } + }) + + @Injectable() + export class RecipeState { + + constructor(private api: RecipeAPI, private store: Store) {} + + @Selector() + static getEditRecipe(state: EditRecipeStateModel) { + return state.editRecipe; + } + + @Action(LoadRecipe) + loadRecipe({setState}: StateContext, {recipeId}: LoadRecipe) { + + this.api.getRecipeById(recipeId).subscribe((recipe) => { + setState({ + editRecipe: recipe, + }); + this.store.dispatch(new Navigate(['/edit-recipe'])); + }, + (error: Error) => { + console.error('Failed to load recipe:', error); + this.store.dispatch(new ShowError(error.message)); + } + ); + } + + } + \ No newline at end of file diff --git a/libs/app/edit-recipe/data-access/src/index.ts b/libs/app/edit-recipe/data-access/src/index.ts new file mode 100644 index 00000000..608595e0 --- /dev/null +++ b/libs/app/edit-recipe/data-access/src/index.ts @@ -0,0 +1,2 @@ +export * from './edit-recipe.module'; +export * from './edit-recipe.state' diff --git a/libs/app/edit-recipe/data-access/src/test-setup.ts b/libs/app/edit-recipe/data-access/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/edit-recipe/data-access/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/edit-recipe/data-access/tsconfig.json b/libs/app/edit-recipe/data-access/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/edit-recipe/data-access/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/edit-recipe/data-access/tsconfig.lib.json b/libs/app/edit-recipe/data-access/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/edit-recipe/data-access/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/edit-recipe/data-access/tsconfig.spec.json b/libs/app/edit-recipe/data-access/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/edit-recipe/data-access/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/edit-recipe/feature/.eslintrc.json b/libs/app/edit-recipe/feature/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/edit-recipe/feature/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/edit-recipe/feature/README.md b/libs/app/edit-recipe/feature/README.md new file mode 100644 index 00000000..c1f54ad2 --- /dev/null +++ b/libs/app/edit-recipe/feature/README.md @@ -0,0 +1,7 @@ +# app-edit-recipe-feature + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-edit-recipe-feature` to execute the unit tests. diff --git a/libs/app/edit-recipe/feature/jest.config.ts b/libs/app/edit-recipe/feature/jest.config.ts new file mode 100644 index 00000000..dc58d251 --- /dev/null +++ b/libs/app/edit-recipe/feature/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-edit-recipe-feature', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/edit-recipe/feature', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/edit-recipe/feature/project.json b/libs/app/edit-recipe/feature/project.json new file mode 100644 index 00000000..70da48a4 --- /dev/null +++ b/libs/app/edit-recipe/feature/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-edit-recipe-feature", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/edit-recipe/feature/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/edit-recipe/feature/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/edit-recipe/feature/**/*.ts", + "libs/app/edit-recipe/feature/**/*.html" + ] + } + } + } +} diff --git a/libs/app/edit-recipe/feature/src/edit-recipe.module.ts b/libs/app/edit-recipe/feature/src/edit-recipe.module.ts new file mode 100644 index 00000000..2e660152 --- /dev/null +++ b/libs/app/edit-recipe/feature/src/edit-recipe.module.ts @@ -0,0 +1,28 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { EditRecipeRouting } from './edit-recipe.routing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { EditRecipeComponent } from './edit-recipe.page'; +import { RecipeDataAccessModule } from '@fridge-to-plate/app/recipe/data-access'; +// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries +import { RecipeModule } from '@fridge-to-plate/app/recipe/feature'; +import { NgxsModule } from '@ngxs/store'; +import {RecipeState as EditRecipeState } from '@fridge-to-plate/app/edit-recipe/data-access' + + +@NgModule({ + imports: [ + CommonModule, + IonicModule, + EditRecipeRouting, + ReactiveFormsModule, + FormsModule, + RecipeDataAccessModule, + RecipeModule, + RecipeDataAccessModule, + NgxsModule.forFeature([EditRecipeState]) + ], + declarations: [EditRecipeComponent] +}) +export class EditRecipeModule {} diff --git a/libs/app/auth/data-access/src/auth.api.ts b/libs/app/edit-recipe/feature/src/edit-recipe.page.css similarity index 100% rename from libs/app/auth/data-access/src/auth.api.ts rename to libs/app/edit-recipe/feature/src/edit-recipe.page.css diff --git a/libs/app/edit-recipe/feature/src/edit-recipe.page.html b/libs/app/edit-recipe/feature/src/edit-recipe.page.html new file mode 100644 index 00000000..b1b29019 --- /dev/null +++ b/libs/app/edit-recipe/feature/src/edit-recipe.page.html @@ -0,0 +1,328 @@ +
+
+

{{ recipe.name }}

+
+
+ +
+
+ +
+
+
+ Recipe Image + + +
+ +
+
+ + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + + +
+
+
+ + + +
+ +
+ + + + + +
+
+ +
+
+ + +
+
+
+
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+
+ +
+ + +
+
+ + +
+
+
+
+ {{ i+1 }}. + + +
+ +
+
+
+ + + +
+ + +
+ + +
+
+
    +
  • + {{ tag }} +
  • +
+
+ +
+ +
+ + +
+
+ +
+ + +
+
+

Unfortunately, No recipe available to be edited

+
+ +
+
+
+
+ diff --git a/libs/app/edit-recipe/feature/src/edit-recipe.page.spec.ts b/libs/app/edit-recipe/feature/src/edit-recipe.page.spec.ts new file mode 100644 index 00000000..9b65e9e4 --- /dev/null +++ b/libs/app/edit-recipe/feature/src/edit-recipe.page.spec.ts @@ -0,0 +1,1185 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { EditRecipeComponent } from './edit-recipe.page'; +import { NgxsModule, State, Store } from '@ngxs/store'; +import { DeleteRecipe, IRecipe, UpdateRecipe } from '@fridge-to-plate/app/recipe/utils'; +import { IProfile, UpdateProfile } from '@fridge-to-plate/app/profile/utils'; +import { Injectable } from '@angular/core'; +import { HttpClientModule } from '@angular/common/http'; +import { NavigationBarModule } from '@fridge-to-plate/app/navigation/feature'; +import { IonicModule } from '@ionic/angular'; +import { RouterTestingModule } from '@angular/router/testing'; +import { ShowError } from '@fridge-to-plate/app/error/utils'; +import { BehaviorSubject, of } from 'rxjs'; +import { Location } from '@angular/common'; + +import { FormArray, FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { IIngredient } from '@fridge-to-plate/app/ingredient/utils'; +import { Navigate } from '@ngxs/router-plugin'; + +describe('EditRecipeComponent', () => { + let component: EditRecipeComponent; + let fixture: ComponentFixture; + + const testProfile: IProfile = { + displayName: "John Doe", + username: "jdoe", + email: "jdoe@gmail.com", + savedRecipes: [], + ingredients: [], + profilePic: "image-url", + createdRecipes: [], + currMealPlan: null, + }; + + @State({ + name: 'profile', + defaults: { + profile: testProfile + } + }) + + @Injectable() + class MockProfileState {} + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [IonicModule, HttpClientModule, NavigationBarModule, RouterTestingModule, NgxsModule.forRoot([MockProfileState])], + declarations: [EditRecipeComponent], + providers: [ + + ] + }).compileComponents(); + + fixture = TestBed.createComponent(EditRecipeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('Should go to Home Page', ()=> { + const storeDispatchSpy = jest.spyOn(TestBed.inject(Store), 'dispatch'); + component.goHome(); + expect(storeDispatchSpy).toHaveBeenCalledWith(new Navigate(['/home'])); + }) + + it('test delete recipe with valid recipe id', () => { + const recipeId = 'valid_recipe_id'; + const deleteRecipeSpy = jest.spyOn(EditRecipeComponent.prototype, 'deleteRecipe'); + const storeDispatchSpy = jest.spyOn(TestBed.inject(Store), 'dispatch'); + const component = fixture.componentInstance; + component.recipe = { recipeId } as IRecipe; + fixture.detectChanges(); + component.deleteRecipe(); + + expect(deleteRecipeSpy).toHaveBeenCalled(); + expect(storeDispatchSpy).toHaveBeenCalledWith(new DeleteRecipe(recipeId)); + }); + + + it('should initialize recipe and recipeId correctly', () => { + const mockRecipe = { recipeId: 'sampleId'} as IRecipe; + jest.spyOn(component.recipe$, 'pipe').mockReturnValue(of(mockRecipe)); + component.initialize() + expect(component.recipe).toEqual(mockRecipe); + expect(component.recipeId).toBe(mockRecipe.recipeId); + expect(component.recipeId).not.toBeUndefined(); + + }); + + it('does not set the recipeId property if the recipe property does not have a recipeId', () => { + component.initialize(); + expect(component.recipeId).toBeUndefined(); +}); + + it('test show error message if recipe id is not available', () => { + const showErrorSpy = jest.spyOn(TestBed.inject(Store), 'dispatch'); + const component = fixture.componentInstance; + component.recipe = null; + fixture.detectChanges(); + component.deleteRecipe(); + + expect(showErrorSpy).toHaveBeenCalledWith(new ShowError('Could not delete recipe')); + }); + + + it('test dispatch delete recipe action with recipe id', () => { + const recipeId = 'valid_recipe_id'; + const storeDispatchSpy = jest.spyOn(TestBed.inject(Store), 'dispatch').mockReturnValue(of(null)); + const locationBackSpy = jest.spyOn(TestBed.inject(Location), 'back'); + const component = fixture.componentInstance; + component.recipe = { recipeId } as IRecipe; + fixture.detectChanges(); + + component.deleteRecipe(); + + expect(storeDispatchSpy).toHaveBeenCalledWith(new DeleteRecipe(recipeId)); + expect(locationBackSpy).toHaveBeenCalled(); + }); + + it('test cancel edit calls location back', () => { + const locationBackSpy = jest.spyOn(TestBed.inject(Location), 'back'); + component.cancelEdit(); + expect(locationBackSpy).toHaveBeenCalled(); +}); + +it('test populateForm with recipe', () => { + component.recipe = { + recipeId: '1', + name: 'Test Recipe', + recipeImage: 'https://img.freepik.com/free-photo/frying-pan-empty-with-various-spices-black-table_1220-561.jpg', + description: 'Test Recipe Description', + meal: 'Breakfast', + creator: '', + ingredients: [ + { + name: 'Test Ingredient', + amount: 1, + unit: 'Test Unit' + } + ], + steps: ['Test Step'], + difficulty: 'Easy', + prepTime: 10, + servings: 2, + tags: ['Test Tag'] + }; + + component.populateForm(); + expect(component.ingredientControls.length).toEqual(1); + expect(component.instructionControls.length).toEqual(1); + expect(component.tags).toEqual(['Test Tag']); + expect(component.selectedMeal).toEqual('Breakfast'); + expect(component.imageUrl).toEqual('https://img.freepik.com/free-photo/frying-pan-empty-with-various-spices-black-table_1220-561.jpg'); + expect(component.difficulty).toEqual('Easy'); +}); + + +}); +@State({ + name: 'create', + defaults: { + recipe: null, + } +}) + +@Injectable() +class MockCreateState {} + + + +describe('Edit Recipe Page', () => { + let editRecipePage: EditRecipeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ EditRecipeComponent ], + imports: [ + ReactiveFormsModule, + IonicModule, + HttpClientModule, + NavigationBarModule, + RouterTestingModule, + NgxsModule.forRoot([MockCreateState]) + ], + providers: [ FormBuilder ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(EditRecipeComponent); + + editRecipePage = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should set the name, description, servings, and preparationTime fields', () => { + + const populateFormSpy = jest.spyOn(EditRecipeComponent.prototype, 'populateForm'); + + const recipe = { + recipeId: '1', + name: 'Test Recipe', + recipeImage: 'https://img.freepik.com/free-photo/frying-pan-empty-with-various-spices-black-table_1220-561.jpg', + description: 'Test Recipe Description', + meal: 'Breakfast', + creator: 'Test Creator', + ingredients: [ + { + name: 'Test Ingredient 1', + amount: 1, + unit: 'Test Unit 1' + }, + { + name: 'Test Ingredient 2', + amount: 2, + unit: 'Test Unit 2' + } + ], + steps: ['Test Step 1', 'Test Step 2'], + difficulty: 'Easy', + prepTime: 10, + servings: 2, + tags: ['Test Tag 1', 'Test Tag 2'] + } as IRecipe; + + const initializeSpy = jest.spyOn(EditRecipeComponent.prototype, 'initialize').mockImplementation( () => { + editRecipePage.recipe = recipe + }); + + editRecipePage.createForm(); + expect(initializeSpy).toBeCalled(); + expect(populateFormSpy).toBeCalled(); + expect(editRecipePage.recipeForm.value.name).toEqual('Test Recipe'); + expect(editRecipePage.recipeForm.value.description).toEqual('Test Recipe Description'); + expect(editRecipePage.recipeForm.value.servings).toEqual(2); + expect(editRecipePage.recipeForm.value.preparationTime).toEqual(10); +}); + + + it('should add a new ingredient control to the form', () => { + const initialLength = editRecipePage.ingredientControls.length; + editRecipePage.addIngredient(); + const newLength = editRecipePage.ingredientControls.length; + expect(newLength).toBe(initialLength + 1); + } + ); + + it('should remove an ingredient control from the form', () => { + const initialLength = editRecipePage.ingredientControls.length; + if(initialLength == 0) { + expect(initialLength).toBe(0) + return + } + editRecipePage.removeIngredient(0); + const newLength = editRecipePage.ingredientControls.length; + expect(newLength).toBe(initialLength - 1); + } + ); + + it('should add a new instruction control to the form', () => { + const initialLength = editRecipePage.instructionControls.length; + editRecipePage.addInstruction(); + const newLength = editRecipePage.instructionControls.length; + expect(newLength).toBe(initialLength + 1); + } + ); + + + it('get instruction steps as String[]', () => { + const formArray = new FormArray([ + new FormControl('Step 1'), + new FormControl('Step 2'), + new FormControl('Step 3'), + ]); + + // create a new recipe form using the form array + const recipeForm = new FormGroup({ + instructions: formArray, + }); + + editRecipePage.recipeForm = recipeForm; + + const instructions = editRecipePage.getInstructions(); + + expect(instructions[0]).toBe('Step 1'); + expect(instructions[1]).toBe('Step 2'); + expect(instructions[2]).toBe('Step 3'); + }) + + + it('should remove an instruction control from the form', () => { + + const formArray = new FormArray([ + new FormControl('Step 1'), + new FormControl('Step 2'), + new FormControl('Step 3'), + ]); + + // create a new recipe form using the form array + const recipeForm = new FormGroup({ + instructions: formArray, + }); + + editRecipePage.recipeForm = recipeForm; + + const initialLength = editRecipePage.instructionControls.length; + editRecipePage.removeInstruction(0); + const newLength = editRecipePage.instructionControls.length; + expect(newLength).toBe(initialLength - 1); + expect(editRecipePage.getInstructions()).toEqual(['Step 2', 'Step 3']) + } + ); +}); + + +describe('Testing Tags', () => { + let component: EditRecipeComponent; + let fb: FormBuilder; + let fixture: ComponentFixture; + let store: Store; + let dispatchSpy: jest.SpyInstance; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ EditRecipeComponent ], + providers: [FormBuilder], + imports: [ + ReactiveFormsModule, + HttpClientModule, + NavigationBarModule, + RouterTestingModule, + NgxsModule.forRoot([MockCreateState]) + ] + }); + + fixture = TestBed.createComponent(EditRecipeComponent); + component = fixture.componentInstance; + fb = TestBed.inject(FormBuilder); + fixture.detectChanges(); + store = TestBed.inject(Store); + dispatchSpy = jest.spyOn(store, 'dispatch'); + + component.recipeForm = fb.group({ + tags: [''], + }); + }); + + it("Should selet a meal type successfully", () => { + const mealType = 'Breakfast'; + component.selectedMeal = mealType; + jest.spyOn(component, 'toggleMeal'); + + // Act + component.toggleMeal(mealType); + + // Assert + expect(component.selectedMeal).toBe(mealType) + expect(component.toggleMeal).toBeCalledWith(mealType) + }) + + it("The selected meals should change when the user changes", () => { + + const mealType = 'Lunch'; + component.selectedMeal = mealType; + + // Act + const mealType2 = 'Dinner'; + // Act + component.toggleMeal(mealType2); + + // Assert + expect(component.selectedMeal).toBe(mealType2); + expect(component.selectedMeal).not.toBe(mealType); + + }) + + it("Should selet a difficulty successfully", () => { + const difficulty = 'Easy'; + component.difficulty = difficulty; + jest.spyOn(component, 'toggleDifficulty'); + + // Act + component.toggleDifficulty(difficulty); + + // Assert + expect(component.difficulty).toBe(difficulty); + expect(component.toggleDifficulty).toBeCalledWith(difficulty); + }) + + it("The selected difficulty should change when the user changes", () => { + + const difficulty1 = 'Easy'; + component.difficulty = difficulty1; + + // Act + const difficulty2 = 'Medium'; + // Act + component.toggleDifficulty(difficulty2); + + // Assert + expect(component.difficulty).toBe(difficulty2); + expect(component.toggleDifficulty).not.toBe(difficulty1); + + }) + + it('should not add a tag if tagValue is empty', () => { + // Arrange + component.recipeForm.get('tag')?.setValue(''); + const size = component.tags.length; + // Act + component.addTag(); + + // Assert + expect(component.tags.length).toBe(size); + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('Please enter valid tag')); + }); + + it('should not add a duplicate tag', () => { + // Arrange + + component.recipeForm.get('tags')?.setValue('Tag'); + const testTags = ['Tag']; + component.tags = testTags; + const size = component.tags.length; + + // Act + component.addTag(); + + // Assert + expect(component.tags.length).toBe(size); + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('No duplicates: Tag already selected')); + expect(component.tags).toEqual(testTags); + + }); + + it('Should not add if tags is already at size three(3)', () => { + // Arrange + component.recipeForm.get('tags')?.setValue('Tag 4'); + const testTags = ['Tag 1', 'Tag 2', 'Tag 3']; + component.tags = testTags; + + // Act + component.addTag(); + + // Assert + expect(component.tags.length).toBe(3); + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('Only a maximum of three tags')); + expect(component.tags).toEqual(testTags); + }) + + it('should add a tag if tagValue is not empty', () => { + // Arrange + component.recipeForm.get('tags')?.setValue('Tag 1'); + + // Act + component.addTag(); + + const testTagsOutput = ['Tag 1']; + // Assert + expect(component.tags.length).toBe(1); + expect(component.tags).toEqual(testTagsOutput); + }); + + it("Should delete a meal tag successfully", () => { + + const testTags = ['Tag 1', 'Tag 2', 'Tag 3']; + component.tags = testTags; + + component.deleteTag(0); + + const testTagsOutput = ['Tag 2', 'Tag 3']; + // Assert + expect(component.tags.length).toBe(2); + expect(component.tags).toEqual(testTagsOutput); + }) + +}); + + +describe('Ingredients storing, deleting and returning', () => { + let component: EditRecipeComponent; + let fixture: ComponentFixture; + let formBuilder: FormBuilder; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ EditRecipeComponent ], + providers: [FormBuilder], + imports: [ + ReactiveFormsModule, + HttpClientModule, + NavigationBarModule, + RouterTestingModule, + NgxsModule.forRoot([MockCreateState]) + ] + }); + fixture = TestBed.createComponent(EditRecipeComponent); + component = fixture.componentInstance; + formBuilder = TestBed.inject(FormBuilder); + + fixture.detectChanges(); + + }); + + it('Gets an array of IIngredient objects ', () => { + // create a mock form array with some form controls + const formArray = new FormArray([ + new FormControl({ + name: 'Mango', + amount: 100, + unit: 'g' + }), + new FormControl({ + name: 'Potato', + amount: 1, + unit: 'kg' + }), + new FormControl({ + name: 'Banana', + amount: 300, + unit: 'g' + }), + new FormControl({ + name: 'Salad', + amount: 100, + unit: 'g' + }), + new FormControl({ + name: 'Onion', + amount: 1, + unit: 'whole' + }), + ]); + + // create a new recipe form using the form array + const recipeForm = new FormGroup({ + ingredients: formArray, + }); + + component.recipeForm = recipeForm; + + const ingredients : IIngredient[] = component.getIngredients(); + + + // assert that the instructions array was created correctly + expect(ingredients[0]).toEqual({ name: "Mango", amount: 100, unit: "g" }); + expect(ingredients[1]).toEqual({ name: "Potato", amount: 1, unit: "kg" }) + expect(ingredients[2]).toEqual({ name: "Banana", amount: 300, unit: "g" }) + expect(ingredients[3]).toEqual({ name: "Salad", amount: 100, unit: "g" }) + expect(ingredients[4]).toEqual({ name: "Onion", amount: 1, unit: "whole" }) + + }) + + it('should remove the ingredient at the specified index', () => { + + component.recipeForm = formBuilder.group({ + ingredients: formBuilder.array([ + formBuilder.group({ + name: ['Ingredient 1', Validators.required], + amount: [1, Validators.required], + scale: ['kg', Validators.required], + }), + formBuilder.group({ + name: ['Ingredient 2', Validators.required], + amount: [2, Validators.required], + scale: ['g', Validators.required], + }), + ]), + }); + + // Arrange + const indexToRemove = 1; + const initialIngredientsCount = component.ingredientControls.length; + + // Act + component.removeIngredient(indexToRemove); + + // Assert + const finalIngredientsCount = component.ingredientControls.length; + expect(finalIngredientsCount).toBe(initialIngredientsCount - 1); + expect(component.ingredientControls[1]).toBeUndefined(); + }); + + }); + + describe("Testing placeholder texts for Amount", () => { + + let component: EditRecipeComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ EditRecipeComponent ], + providers: [FormBuilder], + imports: [ + ReactiveFormsModule, + HttpClientModule, + NavigationBarModule, + RouterTestingModule, + NgxsModule.forRoot([MockCreateState]) + ] + }); + fixture = TestBed.createComponent(EditRecipeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should return "e.g 10" when window width is less than 1024', () => { + // Arrange + global.innerWidth = 800; // Set the window width to a value less than 1024 + + // Act + const placeholderText = component.getAmountPlaceholderText(); + + // Assert + expect(placeholderText).toBe('e.g 10'); + }); + + it('should return "Amount" when window width is greater than or equal to 1024', () => { + // Arrange + global.innerWidth = 1200; // Set the window width to a value greater than or equal to 1024 + + // Act + const placeholderText = component.getAmountPlaceholderText(); + + // Assert + expect(placeholderText).toBe('Amount'); + }); + }) + + describe("Testing placeholder texts for Unit", () => { + + let component: EditRecipeComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ EditRecipeComponent ], + providers: [FormBuilder], + imports: [ + ReactiveFormsModule, + HttpClientModule, + NavigationBarModule, + RouterTestingModule, + NgxsModule.forRoot([MockCreateState]) + ] + }); + fixture = TestBed.createComponent(EditRecipeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should return "e.g 10" when window width is less than 1024', () => { + // Arrange + global.innerWidth = 800; // Set the window width to a value less than 1024 + + // Act + const placeholderText = component.getUnitPlaceholderText(); + + // Assert + expect(placeholderText).toBe('e.g L'); + }); + + it('should return "Amount" when window width is greater than or equal to 1024', () => { + // Arrange + global.innerWidth = 1200; // Set the window width to a value greater than or equal to 1024 + + // Act + const placeholderText = component.getUnitPlaceholderText(); + + // Assert + expect(placeholderText).toBe('Unit'); + }); + }) + + describe("Image upload", () => { + + let component: EditRecipeComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ EditRecipeComponent ], + providers: [FormBuilder], + imports: [ + ReactiveFormsModule, + HttpClientModule, + NavigationBarModule, + RouterTestingModule, + NgxsModule.forRoot([MockCreateState]) + ] + }); + fixture = TestBed.createComponent(EditRecipeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should update the imageUrl when a file is selected', () => { + // Arrange + const file = new File(['sample content'], 'sample.jpg', { type: 'image/jpeg' }); + const event = { target: { files: [file] } }; + const existingImage = component.imageUrl; + + const readAsDataURLStringSpy = jest.spyOn(FileReader.prototype, 'readAsDataURL'); + + // Act + component.onFileChanged(event); + + // Assert + expect(readAsDataURLStringSpy).toHaveBeenCalledWith(file); + + const reader = new FileReader(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + reader.addEventListener("load", function(event) { + expect(component.imageUrl).toBe(file.name); + expect(component.imageUrl).not.toBe(existingImage); + }); + }); + + }); + + describe('isFormValid()', () =>{ + + let component: EditRecipeComponent; + let fixture: ComponentFixture; + let store: Store; + let dispatchSpy: jest.SpyInstance; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ EditRecipeComponent ], + providers: [FormBuilder], + imports: [ + ReactiveFormsModule, + HttpClientModule, + NavigationBarModule, + RouterTestingModule, + NgxsModule.forRoot([MockCreateState]) + ] + }); + fixture = TestBed.createComponent(EditRecipeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + store = TestBed.inject(Store); + dispatchSpy = jest.spyOn(store, 'dispatch'); + }); + + it('At least one ingredient should present', () => { + + const formBuilder: FormBuilder = new FormBuilder(); + + const formGroup: FormGroup = formBuilder.group({ + name: ['Name', Validators.required], + description: ['Description', Validators.required], + servings: [1, Validators.required], + preparationTime: [1, Validators.required], + ingredients: new FormArray([]) + }) + + component.recipeForm = formGroup; + component.isFormValid(); + + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('No Ingredients')); + + }) + + it('At least one instruction step should present', () => { + + const formBuilder: FormBuilder = new FormBuilder(); + const ingredientsFormArray = new FormArray([ + new FormControl({ + name: 'Mango', + amount: 100, + unit: 'g' + })]) + + const formGroup: FormGroup = formBuilder.group({ + name: ['Name', Validators.required], + description: ['Description', Validators.required], + servings: [1, Validators.required], + preparationTime: [1, Validators.required], + ingredients: ingredientsFormArray, + instructions: new FormArray([]) + }) + + component.recipeForm = formGroup; + component.isFormValid(); + + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('No Instructions')); + + }) + + it('Tags if empty', () => { + const formBuilder: FormBuilder = new FormBuilder(); + const ingredientsFormArray = new FormArray([ + new FormControl({ + name: 'Mango', + amount: 100, + unit: 'g' + })]) + const instructionsFormArray = new FormArray([ + new FormControl('Step 1') + ]); + + const formGroup: FormGroup = formBuilder.group({ + name: ['Name', Validators.required], + description: ['Description', Validators.required], + servings: [1, Validators.required], + preparationTime: [1, Validators.required], + ingredients: ingredientsFormArray, + instructions: instructionsFormArray + }) + + component.recipeForm = formGroup; + component.isFormValid(); + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('No Tags')); + }); + + + it('Meal Selection', () => { + const formBuilder: FormBuilder = new FormBuilder(); + const ingredientsFormArray = new FormArray([ + new FormControl({ + name: 'Mango', + amount: 100, + unit: 'g' + })]) + const instructionsFormArray = new FormArray([ + new FormControl('Step 1') + ]); + + const formGroup: FormGroup = formBuilder.group({ + ingredients: ingredientsFormArray, + instructions: instructionsFormArray + }) + + component.tags = ['Asian'] + + component.recipeForm = formGroup; + component.isFormValid(); + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('Please select a meal')); + + }) + + + it('Truthy Profile', () => { + const formBuilder: FormBuilder = new FormBuilder(); + const ingredientsFormArray = new FormArray([ + new FormControl({ + name: 'Mango', + amount: 100, + unit: 'g' + })]) + const instructionsFormArray = new FormArray([ + new FormControl('Step 1') + ]); + + const formGroup: FormGroup = formBuilder.group({ + name: ['Name', Validators.required], + description: ['Description', Validators.required], + servings: [1, Validators.required], + preparationTime: [1, Validators.required], + ingredients: ingredientsFormArray, + instructions: instructionsFormArray + }) + + component.recipeForm = formGroup; + component.tags = ['Asian']; + component.selectedMeal = 'Breakfast'; + component.isFormValid(); + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('Please login to create a recipe')); + }) + + + it('Form fields validation', () => { + const formBuilder: FormBuilder = new FormBuilder(); + const ingredientsFormArray = new FormArray([ + new FormControl({ + name: 'Mango', + amount: 100, + unit: 'g' + })]) + const instructionsFormArray = new FormArray([ + new FormControl('Step 1') + ]); + + const formGroup: FormGroup = formBuilder.group({ + name: ['', Validators.required], + description: ['', Validators.required], + servings: [1, Validators.required], + preparationTime: [1, Validators.required], + ingredients: ingredientsFormArray, + instructions: instructionsFormArray, + tags: formBuilder.array([]), + }) + + const testProfile: IProfile = { + displayName: "John Doe", + username: "jdoe", + email: "jdoe@gmail.com", + savedRecipes: [], + ingredients: [], + profilePic: "image-url", + createdRecipes: [], + currMealPlan: null, + }; + + component.recipeForm = formGroup; + component.selectedMeal = 'Breakfast'; + component.tags = ['Asian']; + component.profile = testProfile; + component.isFormValid(); + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('Incomplete Form. Please fill out every field.')) + }) + + + it('The form should test valid', () => { + const formBuilder: FormBuilder = new FormBuilder(); + + const stepsFormArray = new FormArray([ + new FormControl('Step 1'), + new FormControl('Step 2'), + new FormControl('Step 3'), + ]); + + const ingredientsFormArray = new FormArray([ + new FormControl({ + name: 'Mango', + amount: 100, + unit: 'g' + }), + new FormControl({ + name: 'Potato', + amount: 1, + unit: 'kg' + }), + new FormControl({ + name: 'Banana', + amount: 300, + unit: 'g' + }), + new FormControl({ + name: 'Salad', + amount: 100, + unit: 'g' + }), + new FormControl({ + name: 'Onion', + amount: 1, + unit: 'whole' + }), + ]); + + const testProfile: IProfile = { + displayName: "John Doe", + username: "jdoe", + email: "jdoe@gmail.com", + savedRecipes: [], + ingredients: [], + profilePic: "image-url", + createdRecipes: [], + currMealPlan: null, + }; + + const formGroup: FormGroup = formBuilder.group({ + name: ['Name', Validators.required], + description: ['Description', Validators.required], + servings: [1, Validators.required], + preparationTime: [1, Validators.required], + ingredients: ingredientsFormArray, + instructions: stepsFormArray, + tags: formBuilder.array([]), + }); + + component.selectedMeal = 'Breakfast'; + component.tags = ['Asian']; + component.profile = testProfile + + component.recipeForm = formGroup; + expect(component.isFormValid()).toBe(true); + }) + + }) + + describe("Testing Recipe Update ", () => { + let component: EditRecipeComponent; + let fb: FormBuilder; + let fixture: ComponentFixture; + let store: Store; + let dispatchSpy: jest.SpyInstance; + + const testProfile: IProfile = { + displayName: "John Doe", + username: "jdoe", + email: "jdoe@gmail.com", + savedRecipes: [], + ingredients: [], + profilePic: "image-url", + createdRecipes: [], + currMealPlan: null, + }; + + @State({ + name: 'profile', + defaults: { + profile: testProfile + } + }) + @Injectable() + class MockProfileState {} + + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ EditRecipeComponent ], + providers: [FormBuilder, Store], + imports: [ + ReactiveFormsModule, + HttpClientModule, + NavigationBarModule, + RouterTestingModule, + NgxsModule.forRoot([MockCreateState, MockProfileState]) + ] + }); + fixture = TestBed.createComponent(EditRecipeComponent); + component = fixture.componentInstance; + fb = TestBed.inject(FormBuilder); + store = TestBed.inject(Store); + dispatchSpy = jest.spyOn(store, 'dispatch'); + }); + + + + it('Should dispatch Update Recipe Action', async () => { + + jest.spyOn(component, 'isFormValid'); + + // Mock the recipe data + const recipe: IRecipe = { + name: "Mock Recipe", + recipeImage: "https://example.com/image.jpg", + description: "Amazing meal for a family", + meal: "Dinner", + creator: '', + ingredients: [ {name: 'ingredient1' , amount : 5, unit : 'L'}, + {name: 'ingredient2' , amount : 3, unit : 'g'} + ], + steps: [ + "Mock instructions", + ], + difficulty: "Easy", + prepTime: 30, + servings: 4, + tags: ["mock", "recipe"], + }; + + component.imageUrl = recipe.recipeImage + // Mock the values and controls used in createRecipe + component.recipeForm = fb.group({ + name: fb.control(recipe.name), + description: fb.control(recipe.description), + difficulty: fb.control(recipe.difficulty), + servings: fb.control(recipe.servings), + preparationTime: fb.control(recipe.prepTime), + ingredients: fb.array(recipe.ingredients.map(ingredient => fb.control(ingredient))), + instructions: fb.array(recipe.steps.map(instruction => fb.control(instruction))), + dietaryPlans: fb.array((recipe.tags || []).map(tag => fb.control(tag))), + }); + + component.tags = recipe.tags; + component.selectedMeal = recipe.meal; + component.profile = testProfile; + component.profile.createdRecipes = [recipe]; + + // Call the createRecipe method + component.updateRecipe(); + expect(dispatchSpy).toHaveBeenCalledWith(new UpdateRecipe(recipe)); + expect(dispatchSpy).toHaveBeenCalledWith(new UpdateProfile(testProfile)); + expect(dispatchSpy).toHaveBeenCalledWith(new Navigate([`/recipe/${recipe.recipeId}`])); + expect(component.recipeForm.valid).toBe(true); + expect(component.isFormValid()).toBe(true) + expect(component.isFormValid).toHaveBeenCalled(); + + }); + + + it('Should dispatch Could not update recipe error', () => { + component = fixture.componentInstance; + jest.spyOn(component, 'isFormValid'); + + // Mock the recipe data + const recipe: IRecipe = { + name: "Mock Recipe", + recipeImage: "https://example.com/image.jpg", + description: "Amazing meal for a family", + meal: "Dinner", + creator: '', + ingredients: [ {name: 'ingredient1' , amount : 5, unit : 'L'}, + {name: 'ingredient2' , amount : 3, unit : 'g'} + ], + steps: [ + "Mock instructions", + ], + difficulty: "Easy", + prepTime: 30, + servings: 4, + tags: ["mock", "recipe"], + }; + + component.imageUrl = recipe.recipeImage + // Mock the values and controls used in createRecipe + component.recipeForm = fb.group({ + name: fb.control(recipe.name), + description: fb.control(recipe.description), + difficulty: fb.control(recipe.difficulty), + servings: fb.control(recipe.servings), + preparationTime: fb.control(recipe.prepTime), + ingredients: fb.array(recipe.ingredients.map(ingredient => fb.control(ingredient))), + instructions: fb.array(recipe.steps.map(instruction => fb.control(instruction))), + dietaryPlans: fb.array((recipe.tags || []).map(tag => fb.control(tag))), + }); + + component.tags = recipe.tags; + component.selectedMeal = recipe.meal; + component.profile = testProfile; + component.profile.createdRecipes = []; + + // Call the createRecipe method + component.updateRecipe(); + expect(dispatchSpy).toHaveBeenCalledWith(new ShowError('Could not update recipe')); + }) + + it('Should not create recipe if form is invalid', () => { + + jest.spyOn(component, 'isFormValid'); + + + const profileDataSubject = new BehaviorSubject(undefined); + // Mock the recipe data + const recipe: IRecipe = { + name: "Mock Recipe", + recipeImage: "https://example.com/image.jpg", + description: "Amazing meal for a family", + meal: "Dinner", + creator: profileDataSubject.value?.username ?? '', + ingredients: [], + steps: [], + difficulty: "Easy", + prepTime: 30, + servings: 4, + tags: ["mock", "recipe"], + }; + + component.imageUrl = recipe.recipeImage + // Mock the values and controls used in createRecipe + component.recipeForm = fb.group({ + name: fb.control(recipe.name), + description: fb.control(recipe.description), + difficulty: fb.control(recipe.difficulty), + servings: fb.control(recipe.servings), + preparationTime: fb.control(recipe.prepTime), + ingredients: fb.array(recipe.ingredients.map(ingredient => fb.control(ingredient))), + instructions: fb.array(recipe.steps.map(instruction => fb.control(instruction))), + dietaryPlans: fb.array((recipe.tags || []).map(tag => fb.control(tag))), + }); + + component.tags = recipe.tags; + component.selectedMeal = recipe.meal; + + // Call the createRecipe method + component.updateRecipe(); + expect(component.isFormValid).toHaveBeenCalled(); + expect(component.isFormValid()).toBe(false) + expect(dispatchSpy).not.toHaveBeenCalledWith(new UpdateRecipe(recipe)); + }) + + + + + + }) diff --git a/libs/app/edit-recipe/feature/src/edit-recipe.page.ts b/libs/app/edit-recipe/feature/src/edit-recipe.page.ts new file mode 100644 index 00000000..6a3e2cd4 --- /dev/null +++ b/libs/app/edit-recipe/feature/src/edit-recipe.page.ts @@ -0,0 +1,331 @@ +import { Component, OnInit } from '@angular/core'; +import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { DeleteRecipe, IRecipe, RetrieveRecipe, UpdateRecipe } from '@fridge-to-plate/app/recipe/utils'; +import { IIngredient } from '@fridge-to-plate/app/ingredient/utils'; +import { Select, Store, ofActionSuccessful, Actions } from '@ngxs/store'; +import { ShowError } from '@fridge-to-plate/app/error/utils'; +import { IProfile, UpdateProfile } from '@fridge-to-plate/app/profile/utils'; +import { Location } from '@angular/common'; +import { ActivatedRoute } from '@angular/router'; +import { RecipeState } from '@fridge-to-plate/app/edit-recipe/data-access'; +import { Observable, take } from 'rxjs'; +import { ProfileState } from '@fridge-to-plate/app/profile/data-access'; +import { Navigate } from '@ngxs/router-plugin'; + +@Component({ + selector: 'fridge-to-plate-edit-recipe', + templateUrl: './edit-recipe.page.html', + styleUrls: ['./edit-recipe.page.css'], +}) +export class EditRecipeComponent implements OnInit { + + recipeForm!: FormGroup; + imageUrl = 'https://img.freepik.com/free-photo/frying-pan-empty-with-various-spices-black-table_1220-561.jpg'; + selectedMeal!: "Breakfast" | "Lunch" | "Dinner" | "Snack" | "Dessert"; + difficulty: "Easy" | "Medium" | "Hard" = "Easy"; + tags: string[] = []; + profile !: IProfile; + recipeId !: string; + recipe !: IRecipe | null; + + @Select(RecipeState.getEditRecipe) recipe$ !: Observable; + @Select(ProfileState.getProfile) profile$ !: Observable; + + constructor(private fb: FormBuilder, private store : Store, private location: Location, public route: ActivatedRoute, private actions$: Actions) {} + + ngOnInit() { + this.createForm(); + this.profile$.pipe(take(1)).subscribe( (profile: IProfile) => {this.profile = profile}) + + } + + createForm(): void { + this.initialize(); + this.recipeForm = this.fb.group({ + name: [this.recipe?.name, Validators.required], + description: [this.recipe?.description, Validators.required], + servings: [this.recipe?.servings, Validators.required], + preparationTime: [this.recipe?.prepTime, Validators.required], + ingredients: this.fb.array([]), + instructions: this.fb.array([]), + tags: [''], + }); + this.populateForm(); + } + + initialize(): void { + this.recipe$.pipe(take(1)).subscribe(recipe => + { + this.recipe = recipe; + if(recipe.recipeId) { + this.recipeId = recipe.recipeId; + } + }); // ? + } + + populateForm(): void { + + this.recipe?.ingredients.forEach((ingredient) => { + const ingredientGroup = this.fb.group({ + name: [ingredient.name, Validators.required], + amount: [ingredient.amount, Validators.required], + unit: [ingredient.unit, Validators.required] + }); + + (this.recipeForm.get('ingredients') as FormArray).push(ingredientGroup); + } + ); + + this.recipe?.steps.forEach((step) => { + this.instructionControls.push(this.fb.control(step, Validators.required)); + } + ); + this.tags = this.recipe?.tags ?? this.tags; + this.selectedMeal = this.recipe?.meal ?? this.selectedMeal; + this.imageUrl = this.recipe?.recipeImage ?? this.imageUrl + this.difficulty = this.recipe?.difficulty ?? this.difficulty; + } + + get ingredientControls() { + return (this.recipeForm.get('ingredients') as FormArray).controls; + } + + addIngredient() { + const ingredientGroup = this.fb.group({ + name: ['', Validators.required], + amount: ['', Validators.required], + unit: ['', Validators.required] + }); + + // Add the new ingredient group to the FormArray + (this.recipeForm.get('ingredients') as FormArray).push(ingredientGroup); + } + + get instructionControls() { + return (this.recipeForm.get('instructions') as FormArray).controls; + } + + addInstruction(): void { + this.instructionControls.push(this.fb.control('', Validators.required)); + } + + removeIngredient(index: number): void { + this.ingredientControls.splice(index, 1); + } + + removeInstruction(index: number) : void{ + this.instructionControls.splice(index, 1); + } + + getAmountPlaceholderText() { + if (window.innerWidth < 1024) { + return "e.g 10"; + } else { + return "Amount"; + } + } + + getUnitPlaceholderText() { + if (window.innerWidth < 1024) { + return "e.g L"; + } else { + return "Unit"; + } + } + + updateRecipe() : void { + // Check first if the form is completely valid + if(!this.isFormValid()) + return; + + // Ingredients array + const ingredients = this.getIngredients(); + + // Instructions array + const instructions = this.getInstructions(); + + // Create Recipe details + const recipe: IRecipe = { + recipeId: this.recipe?.recipeId, + name: this.recipeForm.value.name, + recipeImage: this.imageUrl, + description: this.recipeForm.value.description, + meal: this.selectedMeal, + creator: this.recipe?.creator ?? '', + ingredients: ingredients, + steps: instructions, + difficulty: this.difficulty, + prepTime: this.recipeForm.value.preparationTime as number, + servings: this.recipeForm.value.servings as number, + tags: this.tags, + }; + + this.profile$.pipe(take(1)).subscribe( (profile: IProfile) => { + const index = profile.createdRecipes.findIndex( recipe => this.recipeId === recipe.recipeId); + if(index === -1) { + this.store.dispatch( new ShowError('Could not update recipe')); + return; + } + this.store.dispatch( new UpdateRecipe(recipe) ); + profile.createdRecipes[index] = recipe; + this.store.dispatch( new UpdateProfile(profile)) + this.store.dispatch(new Navigate([`/recipe/${this.recipeId}`])) + }) + + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onFileChanged(event: any) { + const file = event.target.files[0]; + const reader = new FileReader(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + reader.onload = (e: any) => { + this.imageUrl = e.target.result; + }; + + reader.readAsDataURL(file); + } + + deleteRecipe() { + + if(!this.recipe?.recipeId) { + this.store.dispatch( new ShowError('Could not delete recipe')); + } + + this.store.dispatch( new DeleteRecipe( this.recipe?.recipeId as string )) + this.profile$.pipe(take(1)).subscribe( (profile: IProfile) => { + profile.createdRecipes = profile.createdRecipes.filter( recipe => this.recipeId !== recipe.recipeId); + this.store.dispatch( new UpdateProfile(profile)) + }) + this.location.back() + } + + toggleMeal(option: "Breakfast" | "Lunch" | "Dinner" | "Snack" | "Dessert") { + this.selectedMeal = option; + } + + getMealPlan(option: string) { + return { + 'bg-primary': this.selectedMeal === option, + 'bg-gray-200': this.selectedMeal !== option, + 'text-white': this.selectedMeal === option, + 'text-gray-700': this.selectedMeal !== option, + 'py-2': true, + 'px-4': true, + 'rounded-md': true, + 'mr-2': true + }; + } + + toggleDifficulty(option: "Easy" | "Medium" | "Hard") { + this.difficulty = option; + } + + getDifficulty(option: string) { + return { + 'bg-primary': this.difficulty === option, + 'bg-gray-200': this.difficulty !== option, + 'text-white': this.difficulty === option, + 'text-gray-700': this.difficulty !== option, + 'py-2': true, + 'px-4': true, + 'rounded-md': true, + 'mr-2': true + }; + } + + + addTag() { + const tagValue = this.recipeForm.get('tags')?.value as string; + if(!tagValue) { + this.store.dispatch( new ShowError("Please enter valid tag")) + } + else if (this.tags.length < 3) { + if(this.tags.includes(tagValue)){ + this.store.dispatch( new ShowError("No duplicates: Tag already selected")) + return; + } + this.tags.push(tagValue); + } + else { + this.store.dispatch( new ShowError("Only a maximum of three tags")) + } + // reset the form value after adding it to array + this.recipeForm.get('tags')?.reset(); + } + + deleteTag(index: number) { + this.tags.splice(index, 1); + } + + isFormValid(): boolean { + + if(!this.recipeForm.valid){ + this.store.dispatch( new ShowError("Incomplete Form. Please fill out every field.")) + return false; + } + + if(this.ingredientControls.length < 1) { + this.store.dispatch( new ShowError("No Ingredients")) + return false; + } + + if(this.instructionControls.length < 1) { + this.store.dispatch( new ShowError("No Instructions")) + return false; + } + + if(this.tags.length < 1) { + this.store.dispatch( new ShowError("No Tags")) + return false; + } + + if(!this.selectedMeal){ + this.store.dispatch( new ShowError("Please select a meal")) + return false; + } + + if(!this.profile){ + this.store.dispatch( new ShowError("Please login to create a recipe")) + return false; + } + + return true; + } + + getIngredients(): IIngredient[] { + const ingredients: IIngredient[] = []; + this.ingredientControls.forEach((ingredient) => { + if (ingredient.value) { + ingredients.push({ + name: ingredient.value.name, + amount: ingredient.value.amount, + unit: ingredient.value.unit + }) + } + }); + + return ingredients; + } + + getInstructions() : string[] { + const instructions: string[] = []; + this.instructionControls.forEach((element) => { + if (element.value) { + instructions.push(element.value); + } + }); + + return instructions; + } + + cancelEdit(): void { + this.location.back(); + } + + goHome(): void { + this.store.dispatch(new Navigate(['/home'])); + } + +} diff --git a/libs/app/edit-recipe/feature/src/edit-recipe.routing.ts b/libs/app/edit-recipe/feature/src/edit-recipe.routing.ts new file mode 100644 index 00000000..fb0614bd --- /dev/null +++ b/libs/app/edit-recipe/feature/src/edit-recipe.routing.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { EditRecipeComponent } from './edit-recipe.page'; + + +const routes: Routes = [ + { + path: '', + pathMatch: 'full', + component: EditRecipeComponent, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class EditRecipeRouting {} \ No newline at end of file diff --git a/libs/app/edit-recipe/feature/src/index.ts b/libs/app/edit-recipe/feature/src/index.ts new file mode 100644 index 00000000..6f00f95c --- /dev/null +++ b/libs/app/edit-recipe/feature/src/index.ts @@ -0,0 +1,3 @@ +export * from './edit-recipe.module'; +export * from './edit-recipe.page'; +export * from './edit-recipe.routing'; diff --git a/libs/app/edit-recipe/feature/src/test-setup.ts b/libs/app/edit-recipe/feature/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/edit-recipe/feature/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/edit-recipe/feature/tsconfig.json b/libs/app/edit-recipe/feature/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/edit-recipe/feature/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/edit-recipe/feature/tsconfig.lib.json b/libs/app/edit-recipe/feature/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/edit-recipe/feature/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/edit-recipe/feature/tsconfig.spec.json b/libs/app/edit-recipe/feature/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/edit-recipe/feature/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/edit-recipe/utils/.eslintrc.json b/libs/app/edit-recipe/utils/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/edit-recipe/utils/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/edit-recipe/utils/README.md b/libs/app/edit-recipe/utils/README.md new file mode 100644 index 00000000..a9110065 --- /dev/null +++ b/libs/app/edit-recipe/utils/README.md @@ -0,0 +1,7 @@ +# app-edit-recipe-utils + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-edit-recipe-utils` to execute the unit tests. diff --git a/libs/app/edit-recipe/utils/jest.config.ts b/libs/app/edit-recipe/utils/jest.config.ts new file mode 100644 index 00000000..09351461 --- /dev/null +++ b/libs/app/edit-recipe/utils/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-edit-recipe-utils', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/edit-recipe/utils', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/edit-recipe/utils/project.json b/libs/app/edit-recipe/utils/project.json new file mode 100644 index 00000000..1ba93108 --- /dev/null +++ b/libs/app/edit-recipe/utils/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-edit-recipe-utils", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/edit-recipe/utils/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/edit-recipe/utils/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/edit-recipe/utils/**/*.ts", + "libs/app/edit-recipe/utils/**/*.html" + ] + } + } + } +} diff --git a/libs/app/edit-recipe/utils/src/edit-recipe.actions.ts b/libs/app/edit-recipe/utils/src/edit-recipe.actions.ts new file mode 100644 index 00000000..04cb7b55 --- /dev/null +++ b/libs/app/edit-recipe/utils/src/edit-recipe.actions.ts @@ -0,0 +1,5 @@ + +export class LoadRecipe { + static readonly type = '[EditRecipe] Load Recipe'; + constructor(public readonly recipeId: string) {} +} \ No newline at end of file diff --git a/libs/app/edit-recipe/utils/src/edit-recipe.module.ts b/libs/app/edit-recipe/utils/src/edit-recipe.module.ts new file mode 100644 index 00000000..8b75a244 --- /dev/null +++ b/libs/app/edit-recipe/utils/src/edit-recipe.module.ts @@ -0,0 +1,7 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@NgModule({ + imports: [CommonModule], +}) +export class AppEditRecipeUtilsModule {} diff --git a/libs/app/edit-recipe/utils/src/index.ts b/libs/app/edit-recipe/utils/src/index.ts new file mode 100644 index 00000000..ab2e3094 --- /dev/null +++ b/libs/app/edit-recipe/utils/src/index.ts @@ -0,0 +1,2 @@ +export * from './edit-recipe.module'; +export * from './edit-recipe.actions' diff --git a/libs/app/edit-recipe/utils/src/test-setup.ts b/libs/app/edit-recipe/utils/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/edit-recipe/utils/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/edit-recipe/utils/tsconfig.json b/libs/app/edit-recipe/utils/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/edit-recipe/utils/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/edit-recipe/utils/tsconfig.lib.json b/libs/app/edit-recipe/utils/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/edit-recipe/utils/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/edit-recipe/utils/tsconfig.spec.json b/libs/app/edit-recipe/utils/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/edit-recipe/utils/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/environments/utils/.eslintrc.json b/libs/app/environments/utils/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/environments/utils/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/environments/utils/README.md b/libs/app/environments/utils/README.md new file mode 100644 index 00000000..0223c8b7 --- /dev/null +++ b/libs/app/environments/utils/README.md @@ -0,0 +1,7 @@ +# app-environments-utils + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-environments-utils` to execute the unit tests. diff --git a/libs/app/environments/utils/jest.config.ts b/libs/app/environments/utils/jest.config.ts new file mode 100644 index 00000000..221bf2e2 --- /dev/null +++ b/libs/app/environments/utils/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-environments-utils', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/environments/utils', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/create/data-access/project.json b/libs/app/environments/utils/project.json similarity index 71% rename from libs/app/create/data-access/project.json rename to libs/app/environments/utils/project.json index 925a46cc..88333096 100644 --- a/libs/app/create/data-access/project.json +++ b/libs/app/environments/utils/project.json @@ -1,7 +1,7 @@ { - "name": "app-create-data-access", + "name": "app-environments-utils", "$schema": "../../../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "libs/app/create/data-access/src", + "sourceRoot": "libs/app/environments/utils/src", "prefix": "fridge-to-plate", "tags": [], "projectType": "library", @@ -10,7 +10,7 @@ "executor": "@nx/jest:jest", "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "options": { - "jestConfig": "libs/app/create/data-access/jest.config.ts", + "jestConfig": "libs/app/environments/utils/jest.config.ts", "passWithNoTests": true }, "configurations": { @@ -25,8 +25,8 @@ "outputs": ["{options.outputFile}"], "options": { "lintFilePatterns": [ - "libs/app/create/data-access/**/*.ts", - "libs/app/create/data-access/**/*.html" + "libs/app/environments/utils/**/*.ts", + "libs/app/environments/utils/**/*.html" ] } } diff --git a/libs/app/environments/utils/src/environment.ts b/libs/app/environments/utils/src/environment.ts new file mode 100644 index 00000000..b0e9241b --- /dev/null +++ b/libs/app/environments/utils/src/environment.ts @@ -0,0 +1,6 @@ +export const environment = { + TYPE: process.env['TYPE'], + API_URL: process.env['API_URL'], + COGNITO_USERPOOL_ID: process.env['COGNITO_USERPOOL_ID'] || 'none', + COGNITO_APP_CLIENT_ID: process.env['COGNITO_APP_CLIENT_ID'] || 'none' +} \ No newline at end of file diff --git a/libs/app/environments/utils/src/index.ts b/libs/app/environments/utils/src/index.ts new file mode 100644 index 00000000..fc56cdff --- /dev/null +++ b/libs/app/environments/utils/src/index.ts @@ -0,0 +1 @@ +export * from './environment'; \ No newline at end of file diff --git a/libs/app/environments/utils/src/test-setup.ts b/libs/app/environments/utils/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/environments/utils/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/environments/utils/tsconfig.json b/libs/app/environments/utils/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/environments/utils/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/environments/utils/tsconfig.lib.json b/libs/app/environments/utils/tsconfig.lib.json new file mode 100644 index 00000000..6eddf60e --- /dev/null +++ b/libs/app/environments/utils/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": ["node"] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/environments/utils/tsconfig.spec.json b/libs/app/environments/utils/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/environments/utils/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/error/data-access/.eslintrc.json b/libs/app/error/data-access/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/error/data-access/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/error/data-access/README.md b/libs/app/error/data-access/README.md new file mode 100644 index 00000000..b91ffecb --- /dev/null +++ b/libs/app/error/data-access/README.md @@ -0,0 +1,7 @@ +# app-error-data-access + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-error-data-access` to execute the unit tests. diff --git a/libs/app/error/data-access/jest.config.ts b/libs/app/error/data-access/jest.config.ts new file mode 100644 index 00000000..197c792f --- /dev/null +++ b/libs/app/error/data-access/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-error-data-access', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/error/data-access', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/error/data-access/project.json b/libs/app/error/data-access/project.json new file mode 100644 index 00000000..aadc2ba2 --- /dev/null +++ b/libs/app/error/data-access/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-error-data-access", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/error/data-access/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/error/data-access/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/error/data-access/**/*.ts", + "libs/app/error/data-access/**/*.html" + ] + } + } + } +} diff --git a/libs/app/error/data-access/src/error.state.ts b/libs/app/error/data-access/src/error.state.ts new file mode 100644 index 00000000..8818adc9 --- /dev/null +++ b/libs/app/error/data-access/src/error.state.ts @@ -0,0 +1,37 @@ +import { Action, State, StateContext } from "@ngxs/store"; +import { Injectable } from '@angular/core'; +import { ShowError } from "@fridge-to-plate/app/error/utils"; +import { ToastController } from "@ionic/angular"; + +export interface ErrorStateModel { + error: string; +} + +@State({ + name: 'error', + defaults: { + error: "" + } +}) + +@Injectable() +export class ErrorState { + + constructor(private toastController: ToastController) {} + + @Action(ShowError) + async showError({ patchState } : StateContext, { error }: ShowError) { + patchState({ + error: error + }); + + const toast = await this.toastController.create({ + message: "ERROR: " + error, + color: 'danger', + duration: 2500, + position: 'bottom', + }); + + await toast.present(); + } +} \ No newline at end of file diff --git a/libs/app/error/data-access/src/index.ts b/libs/app/error/data-access/src/index.ts new file mode 100644 index 00000000..de339a1a --- /dev/null +++ b/libs/app/error/data-access/src/index.ts @@ -0,0 +1 @@ +export * from './error.state'; diff --git a/libs/app/error/data-access/src/test-setup.ts b/libs/app/error/data-access/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/error/data-access/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/error/data-access/tsconfig.json b/libs/app/error/data-access/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/error/data-access/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/error/data-access/tsconfig.lib.json b/libs/app/error/data-access/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/error/data-access/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/error/data-access/tsconfig.spec.json b/libs/app/error/data-access/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/error/data-access/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/error/utils/.eslintrc.json b/libs/app/error/utils/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/error/utils/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/error/utils/README.md b/libs/app/error/utils/README.md new file mode 100644 index 00000000..98c97d1d --- /dev/null +++ b/libs/app/error/utils/README.md @@ -0,0 +1,7 @@ +# app-error-utils + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-error-utils` to execute the unit tests. diff --git a/libs/app/error/utils/jest.config.ts b/libs/app/error/utils/jest.config.ts new file mode 100644 index 00000000..6cefb690 --- /dev/null +++ b/libs/app/error/utils/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-error-utils', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/error/utils', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/error/utils/project.json b/libs/app/error/utils/project.json new file mode 100644 index 00000000..40013f65 --- /dev/null +++ b/libs/app/error/utils/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-error-utils", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/error/utils/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/error/utils/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/error/utils/**/*.ts", + "libs/app/error/utils/**/*.html" + ] + } + } + } +} diff --git a/libs/app/error/utils/src/error.actions.ts b/libs/app/error/utils/src/error.actions.ts new file mode 100644 index 00000000..5ed22156 --- /dev/null +++ b/libs/app/error/utils/src/error.actions.ts @@ -0,0 +1,5 @@ +export class ShowError { + static readonly type = '[Errors] ShowError'; + constructor(public readonly error: string) {} + } + \ No newline at end of file diff --git a/libs/app/auth/data-access/src/auth.module.ts b/libs/app/error/utils/src/error.module.ts similarity index 78% rename from libs/app/auth/data-access/src/auth.module.ts rename to libs/app/error/utils/src/error.module.ts index 8436acac..51501c0d 100644 --- a/libs/app/auth/data-access/src/auth.module.ts +++ b/libs/app/error/utils/src/error.module.ts @@ -4,4 +4,4 @@ import { CommonModule } from '@angular/common'; @NgModule({ imports: [CommonModule], }) -export class AuthDataAccessModule {} +export class ErrorUtilsModule {} diff --git a/libs/app/error/utils/src/index.ts b/libs/app/error/utils/src/index.ts new file mode 100644 index 00000000..6b00d89b --- /dev/null +++ b/libs/app/error/utils/src/index.ts @@ -0,0 +1,2 @@ +export * from './error.module'; +export * from './error.actions'; \ No newline at end of file diff --git a/libs/app/error/utils/src/test-setup.ts b/libs/app/error/utils/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/error/utils/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/error/utils/tsconfig.json b/libs/app/error/utils/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/error/utils/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/error/utils/tsconfig.lib.json b/libs/app/error/utils/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/error/utils/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/error/utils/tsconfig.spec.json b/libs/app/error/utils/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/error/utils/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/explore/data-access/.eslintrc.json b/libs/app/explore/data-access/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/explore/data-access/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/explore/data-access/README.md b/libs/app/explore/data-access/README.md new file mode 100644 index 00000000..9befe122 --- /dev/null +++ b/libs/app/explore/data-access/README.md @@ -0,0 +1,7 @@ +# app-explore-data-access + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-explore-data-access` to execute the unit tests. diff --git a/libs/app/explore/data-access/jest.config.ts b/libs/app/explore/data-access/jest.config.ts new file mode 100644 index 00000000..e2e3081e --- /dev/null +++ b/libs/app/explore/data-access/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-explore-data-access', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/explore/data-access', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/explore/data-access/project.json b/libs/app/explore/data-access/project.json new file mode 100644 index 00000000..5400787a --- /dev/null +++ b/libs/app/explore/data-access/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-explore-data-access", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/explore/data-access/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/explore/data-access/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/explore/data-access/**/*.ts", + "libs/app/explore/data-access/**/*.html" + ] + } + } + } +} diff --git a/libs/app/explore/data-access/src/explore.api.ts b/libs/app/explore/data-access/src/explore.api.ts new file mode 100644 index 00000000..1ab94374 --- /dev/null +++ b/libs/app/explore/data-access/src/explore.api.ts @@ -0,0 +1,38 @@ +import { ingredientsArray } from './explore.mock'; +import { IRecipe } from '@fridge-to-plate/app/recipe/utils'; +import { IIngredient } from '@fridge-to-plate/app/ingredient/utils'; +import { HttpClient } from '@angular/common/http'; +import { BehaviorSubject, Observable, catchError, switchMap } from 'rxjs'; +import { Injectable } from '@angular/core'; +import { IProfile } from '@fridge-to-plate/app/profile/utils'; +import { Store } from '@ngxs/store'; +import { IExplore } from '@fridge-to-plate/app/explore/utils'; +import { environment } from '@fridge-to-plate/app/environments/utils'; + +@Injectable({ + providedIn: 'root', +}) +export class ExploreAPI { + + constructor(private http: HttpClient, private store: Store) {} + + private baseUrl = environment.API_URL + "/explore"; + + getRecipes(recipename: string) { + const url = `${this.baseUrl}/${recipename}`; + + return this.http.get(url); + } + + getProfile(username: string) { + const url = `${this.baseUrl}/profiles/${username}`; + + return this.http.get(url); + } + + searchCategory(search : IExplore): Observable { + const url = `${this.baseUrl}/search`; + return this.http.post(url, search); + } + +} diff --git a/libs/app/explore/data-access/src/explore.mock.ts b/libs/app/explore/data-access/src/explore.mock.ts new file mode 100644 index 00000000..5e5310cb --- /dev/null +++ b/libs/app/explore/data-access/src/explore.mock.ts @@ -0,0 +1,84 @@ +import { IIngredient } from '@fridge-to-plate/app/ingredient/utils'; + +export const ingredientsArray: IIngredient[] = [ + { + name: 'Tomato', + amount: 2, + unit: 'kg', + }, + { + name: 'Onion', + amount: 1, + unit: 'kg', + }, + { + name: 'Rice', + amount: 3, + unit: 'kg', + }, + { + name: 'Chicken', + amount: 2, + unit: 'kg', + }, + { + name: 'Rump Steak', + amount: 3, + unit: 'kg', + }, + { + name: 'Rice', + amount: 3, + unit: 'kg', + }, + { + name: 'Flour', + amount: 2, + unit: 'kg', + }, + { + name: 'Egg', + amount: 500, + unit: 'g', + }, + { + name: 'Peppers', + amount: 2, + unit: 'kg', + }, + { + name: 'Sunflower Oil', + amount: 2, + unit: 'l', + }, + { + name: 'Milk', + amount: 4, + unit: 'l', + }, + { + name: 'Soy Sauce', + amount: 500, + unit: 'ml', + }, + { + name: 'Beef Stock', + amount: 200, + unit: 'ml', + }, + { + name: 'Pasta', + amount: 2, + unit: 'kg', + }, + { + name: 'Salt', + amount: 200, + unit: 'g', + }, + { + name: 'Salmon', + amount: 1, + unit: 'kg', + }, +]; diff --git a/libs/app/explore/data-access/src/explore.module.ts b/libs/app/explore/data-access/src/explore.module.ts new file mode 100644 index 00000000..9a813643 --- /dev/null +++ b/libs/app/explore/data-access/src/explore.module.ts @@ -0,0 +1,8 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ExploreAPI } from './explore.api'; + +@NgModule({ + imports: [CommonModule], +}) +export class ExploreDataAccessModule {} diff --git a/libs/app/explore/data-access/src/explore.state.ts b/libs/app/explore/data-access/src/explore.state.ts new file mode 100644 index 00000000..02889fb8 --- /dev/null +++ b/libs/app/explore/data-access/src/explore.state.ts @@ -0,0 +1,70 @@ +import { Injectable } from "@angular/core"; +import { CategorySearch, RetrieveProfile, RetrieveRecipe } from "@fridge-to-plate/app/explore/utils"; +import { Action, Selector, State, StateContext, Store } from "@ngxs/store"; +import { ExploreAPI } from "./explore.api"; +import { IExplore } from "@fridge-to-plate/app/explore/utils"; +import { IIngredient } from "@fridge-to-plate/app/ingredient/utils"; +import { IRecipe } from "@fridge-to-plate/app/recipe/utils"; +import { ShowError } from "@fridge-to-plate/app/error/utils"; +import { Navigate } from "@ngxs/router-plugin"; + +export interface ExploreStateModel { + explore: IExplore | null; + recipes: IRecipe[] | null; + +} + +@State({ + name: 'explore', + defaults: { + explore: { + type: "", + search : "", + tags : [], + difficulty : "Any", + }, + recipes: [], + } +}) + + +@Injectable() +export class ExploreState { + + constructor(private store: Store, private exploreAPI: ExploreAPI) {} + + @Selector() + static getExplore(state: ExploreStateModel) { + return state.explore; + } + + @Selector() + static getRecipes(state: ExploreStateModel) { + return state.recipes; + } + + @Action(CategorySearch) + async CategorySearch({ setState } : StateContext, { search } : CategorySearch) { + + setState({ + explore: search, + recipes: null + }); + + (await this.exploreAPI.searchCategory(search)).subscribe({ + next: data => { + + setState({ + explore: search, + recipes: data + }); + }, + error: error => { + this.store.dispatch(new ShowError(error)); + console.log(error); + } + }); + + } + +} diff --git a/libs/app/explore/data-access/src/index.ts b/libs/app/explore/data-access/src/index.ts new file mode 100644 index 00000000..8d169fcb --- /dev/null +++ b/libs/app/explore/data-access/src/index.ts @@ -0,0 +1,3 @@ +export * from './explore.module'; +export * from './explore.state'; +export * from './explore.api'; diff --git a/libs/app/recommend/data-access/src/store.state.ts b/libs/app/explore/data-access/src/store.state.ts similarity index 53% rename from libs/app/recommend/data-access/src/store.state.ts rename to libs/app/explore/data-access/src/store.state.ts index 168bca5a..a2203ee5 100644 --- a/libs/app/recommend/data-access/src/store.state.ts +++ b/libs/app/explore/data-access/src/store.state.ts @@ -1,18 +1,18 @@ -import { IngredientItem, ingredientsArray } from './ingredients.mock'; -// import {recipeArray} from "./recipes.mock"; +import { ingredientsArray } from './explore.mock'; +import { IIngredient } from '@fridge-to-plate/app/ingredient/utils'; export function getAllIngredients() { return ingredientsArray; } export function addIngredient( - ingredient: IngredientItem, - ingredents: IngredientItem[] + ingredient: IIngredient, + ingredents: IIngredient[] ) { if (!ingredient) return; const item = ingredents.find( - (item) => item.ingredientId === ingredient.ingredientId + (item) => item.name === ingredient.name ); if (item) return; @@ -21,19 +21,19 @@ export function addIngredient( } export function removeIngredient( - ingredient: IngredientItem, - ingredients: IngredientItem[] -): IngredientItem[] { + ingredient: IIngredient, + ingredients: IIngredient[] +): IIngredient[] { if (!ingredient) return ingredients; const item = ingredients.find( - (item) => item.ingredientId === ingredient.ingredientId + (item) => item.name === ingredient.name ); if (!item) return ingredients; ingredients = ingredients.filter( - (ing) => ing.ingredientId !== item.ingredientId + (ing) => ing.name !== item.name ); return ingredients; diff --git a/libs/app/explore/data-access/src/test-setup.ts b/libs/app/explore/data-access/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/explore/data-access/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/explore/data-access/tsconfig.json b/libs/app/explore/data-access/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/explore/data-access/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/explore/data-access/tsconfig.lib.json b/libs/app/explore/data-access/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/explore/data-access/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/explore/data-access/tsconfig.spec.json b/libs/app/explore/data-access/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/explore/data-access/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/explore/feature/.eslintrc.json b/libs/app/explore/feature/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/explore/feature/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/explore/feature/README.md b/libs/app/explore/feature/README.md new file mode 100644 index 00000000..15d08e1a --- /dev/null +++ b/libs/app/explore/feature/README.md @@ -0,0 +1,7 @@ +# app-explore-feature + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-explore-feature` to execute the unit tests. diff --git a/libs/app/explore/feature/jest.config.ts b/libs/app/explore/feature/jest.config.ts new file mode 100644 index 00000000..d2fd4488 --- /dev/null +++ b/libs/app/explore/feature/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-explore-feature', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/explore/feature', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/explore/feature/project.json b/libs/app/explore/feature/project.json new file mode 100644 index 00000000..1964d19b --- /dev/null +++ b/libs/app/explore/feature/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-explore-feature", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/explore/feature/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/explore/feature/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/explore/feature/**/*.ts", + "libs/app/explore/feature/**/*.html" + ] + } + } + } +} diff --git a/libs/app/explore/feature/src/explore.module.ts b/libs/app/explore/feature/src/explore.module.ts new file mode 100644 index 00000000..096d8e19 --- /dev/null +++ b/libs/app/explore/feature/src/explore.module.ts @@ -0,0 +1,39 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ExploreRouting } from './explore.routing'; +import { IonicModule } from '@ionic/angular'; +import { NzStepsModule } from 'ng-zorro-antd/steps'; +import { NzListModule } from 'ng-zorro-antd/list'; +import { ReactiveFormsModule } from '@angular/forms'; +import { NzFormModule } from 'ng-zorro-antd/form'; +import { NzInputModule } from 'ng-zorro-antd/input'; +import { NzIconModule } from 'ng-zorro-antd/icon'; +import { ExploreUIModule } from '@fridge-to-plate/app/explore/ui'; +import { ExplorePage } from './explore.page'; +import { RecipeUIModule } from '@fridge-to-plate/app/recipe/ui'; +import { ExploreDataAccessModule } from '@fridge-to-plate/app/explore/data-access'; +import { NavigationBarModule } from '@fridge-to-plate/app/navigation/feature'; +import { RecipeModule } from "@fridge-to-plate/app/recipe/feature"; + + +@NgModule({ + imports: [ + CommonModule, + ExploreRouting, + IonicModule, + NzStepsModule, + NzListModule, + ReactiveFormsModule, + NzFormModule, + NzInputModule, + NzIconModule, + ExploreUIModule, + ExploreDataAccessModule, + NavigationBarModule, + RecipeModule, + RecipeUIModule + ], + declarations: [ExplorePage], + exports: [ExplorePage], +}) +export class ExploreModule {} diff --git a/libs/app/explore/feature/src/explore.page.html b/libs/app/explore/feature/src/explore.page.html new file mode 100644 index 00000000..787806b2 --- /dev/null +++ b/libs/app/explore/feature/src/explore.page.html @@ -0,0 +1,34 @@ +
+ +
+ + +
+

Categories

+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + Loading... + +
+
+ +
diff --git a/libs/app/profile/ui/src/edit-modal/edit-modal.component.css b/libs/app/explore/feature/src/explore.page.scss similarity index 100% rename from libs/app/profile/ui/src/edit-modal/edit-modal.component.css rename to libs/app/explore/feature/src/explore.page.scss diff --git a/libs/app/explore/feature/src/explore.page.spec.ts b/libs/app/explore/feature/src/explore.page.spec.ts new file mode 100644 index 00000000..5688303d --- /dev/null +++ b/libs/app/explore/feature/src/explore.page.spec.ts @@ -0,0 +1,126 @@ +/* eslint-disable prefer-const */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Store, NgxsModule, State } from '@ngxs/store'; +import { Observable, of } from 'rxjs'; +import { ExplorePage } from './explore.page'; +import { ExploreState, } from '@fridge-to-plate/app/explore/data-access'; +import { CategorySearch, IExplore } from '@fridge-to-plate/app/explore/utils'; +import { IRecipe } from '@fridge-to-plate/app/recipe/utils'; +import { Injectable } from '@angular/core'; + + +// Create a mock of the Ngxs selector +class MockExploreState { + static getExplore = jest.fn(() => of({} as IExplore)); + static getRecipes = jest.fn(() => of([])); +} + +describe('ExplorePage', () => { + let component: ExplorePage; + let fixture: ComponentFixture; + let store: Store; + + const mockExplore: IExplore = { + type: 'breakfast', + search: 'eggs', + tags: ['healthy', 'quick'], + difficulty: 'Easy', + }; + + const mockRecipes: IRecipe[] = [ + { + recipeId: '1', + name: 'Scrambled Eggs', + tags: ['breakfast', 'easy', 'healthy'], + difficulty: 'Easy', + recipeImage: 'scrambled-eggs.jpg', + description: 'Delicious scrambled eggs recipe', + servings: 2, + prepTime: 10, + meal: 'Breakfast', + ingredients: [ + { name: 'Eggs', amount: 4, unit: 'No.' }, + { name: 'Milk', amount: 2, unit: 'tbsp' }, + { name: 'Salt', amount: 1 / 4, unit: 'tsp' }, + ], + steps: ['Whisk the eggs and milk in a bowl', 'Add salt and mix well', 'Cook in a non-stick pan until fluffy'], + creator: 'John Doe', + }, + ]; + + @State({ + name: 'explore', + defaults: { + explore: mockExplore, + recipes: mockRecipes, + }, + }) + @Injectable() + class MockExploreState {} + + beforeEach(async () => { + + await TestBed.configureTestingModule({ + imports: [NgxsModule.forRoot([MockExploreState])], + declarations: [ExplorePage], + }).compileComponents(); + + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ExplorePage); + component = fixture.componentInstance; + store = TestBed.inject(Store); + jest.spyOn(store, 'dispatch'); // Spy on store.dispatch to check if it's called + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display recipes when search is applied by category', () => { + + component.search(mockExplore); + + expect(store.dispatch).toHaveBeenCalledWith(new CategorySearch(mockExplore)); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(component.retunedRecipes).toEqual(mockRecipes); + expect(component.loading).toBe(false); + expect(component.showRecipes).toBe(true); + }); + }); + + it('should display recipes when explorer search is applied', () => { + + component.explorer('searchText'); + + expect(store.dispatch).toHaveBeenCalledWith(new CategorySearch(component.searchObject)); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(component.retunedRecipes).toEqual(mockRecipes); + expect(component.loading).toBe(false); + expect(component.showRecipes).toBe(true); + }); + + }); + + it('should show categories when explorer search text is empty', () => { + component.explorer(''); + + expect(component.loading).toBe(false); + expect(component.showRecipes).toBe(false); + expect(component.showCategories).toBe(true); + }); + + it('should clear the search', () => { + component.clearSearch(); + + expect(component.subpage).toBe('beforeSearchApplied'); + expect(component.showCategories).toBe(true); + expect(component.showRecipes).toBe(false); + expect(component.loading).toBe(false); + }); +}); diff --git a/libs/app/explore/feature/src/explore.page.ts b/libs/app/explore/feature/src/explore.page.ts new file mode 100644 index 00000000..0ac30121 --- /dev/null +++ b/libs/app/explore/feature/src/explore.page.ts @@ -0,0 +1,162 @@ +import { Component, Input } from '@angular/core'; +import { Select, Store, NgxsModule } from '@ngxs/store'; +import { ExploreState } from "@fridge-to-plate/app/explore/data-access"; +import { CategorySearch, IExplore } from '@fridge-to-plate/app/explore/utils'; +import { Observable, take } from "rxjs"; +import { NavigationBar } from "@fridge-to-plate/app/navigation/feature"; +import { Navigate } from "@ngxs/router-plugin"; +import { IRecipe } from "@fridge-to-plate/app/recipe/utils" +import { RecipeUIModule } from "@fridge-to-plate/app/recipe/ui"; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'explore-page', + templateUrl: './explore.page.html', + styleUrls: ['./explore.page.scss'], +}) + +// eslint-disable-next-line @angular-eslint/component-class-suffix +export class ExplorePage { + + @Select(ExploreState.getExplore) explore$ !: Observable; + @Select(ExploreState.getRecipes) recipes$ !: Observable; + + page = "searching"; + retunedRecipes: any; + subpage = "beforeSearchApplied"; + loading = false; + showRecipes = false; + showCategories = true; + currSearch = false; + editExplore !: IExplore; + searchObject !: IExplore; + + + allCategories : IExplore[] = [ + { + type: "breakfast", + search: "", + tags: [], + difficulty: "Any", + }, + { + type: "snack", + search: "", + tags: [], + difficulty: "Any", + }, + { + type: "lunch", + search: "", + tags: [], + difficulty: "Any", + }, + { + type: "dessert", + search: "", + tags: [], + difficulty: "Any", + }, + { + type: "dinner", + search: "", + tags: [], + difficulty: "Any", + }, + // { + // type: "soup", + // search: "", + // tags: [], + // difficulty: "Any", + // }, + // { + // type: "drink", + // search: "", + // tags: [], + // difficulty: "Any", + // }, + // { + // type: "salad", + // search: "", + // tags: [], + // difficulty: "Any", + // }, + + ]; + + + constructor(private store: Store) { + + } + + displaySearch = "block"; + + + // eslint-disable-next-line @typescript-eslint/ban-types + search(search : IExplore) { + + this.subpage = "searchAppliedByCaterogry" + this.showRecipes = false; + this.loading = true; + this.currSearch = true; + + this.store.dispatch(new CategorySearch(search)); + + this.recipes$.subscribe( (recipes) => { + if(recipes && recipes.length > 0 && this.currSearch){ + this.retunedRecipes = recipes; + this.loading = false; + this.showRecipes = true; + this.currSearch = false; + } + }) + + } + + explorer(searchText: string) { + + if (searchText.length > 0) { + this.showCategories = false; + this.loading = true; + this.showRecipes = false; + this.currSearch = true; + } + else { + this.loading = false; + this.showRecipes = false; + this.showCategories = true; + this.subpage = "beforeSearchApplied"; + this.currSearch = false; + + return; + } + + this.searchObject = + { + type: "", + search: searchText, + tags: [], + difficulty: "Any", + }; + + this.store.dispatch(new CategorySearch(this.searchObject)); + + this.recipes$.subscribe( (recipes) => { + if(recipes && recipes.length > 0 && this.currSearch){ + this.retunedRecipes = recipes; + this.loading = false; + this.showRecipes = true; + this.currSearch = false; + } + }); + + } + + clearSearch(){ + this.subpage = "beforeSearchApplied"; + this.showCategories = true; + this.showRecipes = false; + this.loading = false; + } + +} diff --git a/libs/app/explore/feature/src/explore.routing.ts b/libs/app/explore/feature/src/explore.routing.ts new file mode 100644 index 00000000..46358161 --- /dev/null +++ b/libs/app/explore/feature/src/explore.routing.ts @@ -0,0 +1,19 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { ExplorePage } from "./explore.page"; + +const routes: Routes = [ + { + path: '', + pathMatch: 'full', + component: ExplorePage + }, +] + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + ], + exports: [RouterModule], +}) +export class ExploreRouting {} diff --git a/libs/app/explore/feature/src/index.ts b/libs/app/explore/feature/src/index.ts new file mode 100644 index 00000000..9fbbac8a --- /dev/null +++ b/libs/app/explore/feature/src/index.ts @@ -0,0 +1 @@ +export * from './explore.module'; diff --git a/libs/app/explore/feature/src/test-setup.ts b/libs/app/explore/feature/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/explore/feature/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/explore/feature/tsconfig.json b/libs/app/explore/feature/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/explore/feature/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/explore/feature/tsconfig.lib.json b/libs/app/explore/feature/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/explore/feature/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/explore/feature/tsconfig.spec.json b/libs/app/explore/feature/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/explore/feature/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/explore/ui/.eslintrc.json b/libs/app/explore/ui/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/explore/ui/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/explore/ui/README.md b/libs/app/explore/ui/README.md new file mode 100644 index 00000000..27bb4db3 --- /dev/null +++ b/libs/app/explore/ui/README.md @@ -0,0 +1,7 @@ +# app-explore-ui + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-explore-ui` to execute the unit tests. diff --git a/libs/app/explore/ui/jest.config.ts b/libs/app/explore/ui/jest.config.ts new file mode 100644 index 00000000..a0da7962 --- /dev/null +++ b/libs/app/explore/ui/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-explore-ui', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/explore/ui', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/explore/ui/project.json b/libs/app/explore/ui/project.json new file mode 100644 index 00000000..3cb526f3 --- /dev/null +++ b/libs/app/explore/ui/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-explore-ui", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/explore/ui/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/explore/ui/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/explore/ui/**/*.ts", + "libs/app/explore/ui/**/*.html" + ] + } + } + } +} diff --git a/libs/app/explore/ui/src/explore-card/explore-card.component.html b/libs/app/explore/ui/src/explore-card/explore-card.component.html new file mode 100644 index 00000000..47fe891b --- /dev/null +++ b/libs/app/explore/ui/src/explore-card/explore-card.component.html @@ -0,0 +1,115 @@ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+
+ + + + +
+

Breakfast

+
+ +
+
+ + + + +
+

Snack

+
+ +
+
+ + + + +
+

Lunch

+
+ +
+
+ + + + +
+

Dessert

+
+ +
+
+ + + + + + +
+ +

Dinner

+
+
+
+ + + + +
+

Soup

+
+ +
+
+ + + + + + +
+

Drink

+
+ +
+
+ + + + +
+ +

Salad

+
+ +
+
diff --git a/libs/app/recommend/ui/src/diet-preference-pill-component/diet-preference-pill-component.component.css b/libs/app/explore/ui/src/explore-card/explore-card.component.scss similarity index 100% rename from libs/app/recommend/ui/src/diet-preference-pill-component/diet-preference-pill-component.component.css rename to libs/app/explore/ui/src/explore-card/explore-card.component.scss diff --git a/libs/app/explore/ui/src/explore-card/explore-card.component.ts b/libs/app/explore/ui/src/explore-card/explore-card.component.ts new file mode 100644 index 00000000..8c8e61b8 --- /dev/null +++ b/libs/app/explore/ui/src/explore-card/explore-card.component.ts @@ -0,0 +1,20 @@ +import { Component, Input } from '@angular/core'; +import { IExplore } from '@fridge-to-plate/app/explore/utils'; +import { Store } from '@ngxs/store'; +import { IonicModule } from '@ionic/angular'; + + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'explore-card', + templateUrl: './explore-card.component.html', + styleUrls: ['./explore-card.component.scss'], +}) +export class ExploreCardComponent { + + //explore : IExplore; + bookmarked = false; + editable = false; + @Input() explore !: any; + +} diff --git a/libs/app/explore/ui/src/explore.module.ts b/libs/app/explore/ui/src/explore.module.ts new file mode 100644 index 00000000..88ade727 --- /dev/null +++ b/libs/app/explore/ui/src/explore.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { SearchingModalComponent } from './searching-modal/searching-modal.component'; +import { ExploreCardComponent } from './explore-card/explore-card.component'; +import { ResultsModalComponent } from './results-modal/results-modal.component'; +import { NgxsModule } from '@ngxs/store'; +import { ExploreState } from '@fridge-to-plate/app/explore/data-access'; + + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + NgxsModule.forRoot([ExploreState]), + ], + declarations: [SearchingModalComponent, ExploreCardComponent, ResultsModalComponent], + exports: [SearchingModalComponent, ExploreCardComponent, ResultsModalComponent] +}) +export class ExploreUIModule {} diff --git a/libs/app/explore/ui/src/index.ts b/libs/app/explore/ui/src/index.ts new file mode 100644 index 00000000..aa1a9f31 --- /dev/null +++ b/libs/app/explore/ui/src/index.ts @@ -0,0 +1,3 @@ +export * from './explore.module'; +export * from './results-modal/results-modal.component'; +export * from './searching-modal/searching-modal.component'; diff --git a/libs/app/explore/ui/src/results-modal/results-modal.component.html b/libs/app/explore/ui/src/results-modal/results-modal.component.html new file mode 100644 index 00000000..7ca4351d --- /dev/null +++ b/libs/app/explore/ui/src/results-modal/results-modal.component.html @@ -0,0 +1,10 @@ + +
+ +

Results

+ + + + +
+ \ No newline at end of file diff --git a/libs/app/explore/ui/src/results-modal/results-modal.component.scss b/libs/app/explore/ui/src/results-modal/results-modal.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/libs/app/explore/ui/src/results-modal/results-modal.component.ts b/libs/app/explore/ui/src/results-modal/results-modal.component.ts new file mode 100644 index 00000000..7bf1ec0f --- /dev/null +++ b/libs/app/explore/ui/src/results-modal/results-modal.component.ts @@ -0,0 +1,20 @@ +import { Component} from '@angular/core'; +import { Navigate } from '@ngxs/router-plugin'; +import { Store } from '@ngxs/store'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'results-modal', + templateUrl: './results-modal.component.html', + styleUrls: ['./results-modal.component.scss'], +}) + +export class ResultsModalComponent { + + constructor(private store: Store) { } + + // viewRecipe() { + // return; + // } + +} diff --git a/libs/app/explore/ui/src/searching-modal/searching-modal.component.html b/libs/app/explore/ui/src/searching-modal/searching-modal.component.html new file mode 100644 index 00000000..41409b78 --- /dev/null +++ b/libs/app/explore/ui/src/searching-modal/searching-modal.component.html @@ -0,0 +1,22 @@ +
+ +
+ + +
+ +
+ +
+ +
diff --git a/libs/app/explore/ui/src/searching-modal/searching-modal.component.scss b/libs/app/explore/ui/src/searching-modal/searching-modal.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/libs/app/explore/ui/src/searching-modal/searching-modal.component.spec.ts b/libs/app/explore/ui/src/searching-modal/searching-modal.component.spec.ts new file mode 100644 index 00000000..29a7edb7 --- /dev/null +++ b/libs/app/explore/ui/src/searching-modal/searching-modal.component.spec.ts @@ -0,0 +1,56 @@ +// Import the required dependencies for testing +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { NgxsModule, Select, Store } from '@ngxs/store'; +import { Observable, of } from 'rxjs'; +import { SearchingModalComponent } from './searching-modal.component'; + + +describe('SearchingModalComponent', () => { + let component: SearchingModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [SearchingModalComponent], + imports: [NgxsModule.forRoot()] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SearchingModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the SearchingModalComponent', () => { + expect(component).toBeTruthy(); + }); + + it('should emit newSearchEvent on explorer() call', () => { + const searchQuery = 'Test search query'; + component.searchText = searchQuery; + + // Create a spy on the newSearchEvent EventEmitter + const emitSpy = jest.spyOn(component.newSearchEvent, 'emit'); + + // Call the explorer() method + component.explorer(); + + fixture.detectChanges(); + fixture.whenStable().then(() => { + // Check if the emit method was called with the correct argument + expect(emitSpy).toHaveBeenCalledWith(searchQuery); + }); + + + }); + + it('should render the search input field', () => { + const inputElement = fixture.debugElement.query(By.css('input')); + expect(inputElement).toBeTruthy(); + }); + +}); + diff --git a/libs/app/explore/ui/src/searching-modal/searching-modal.component.ts b/libs/app/explore/ui/src/searching-modal/searching-modal.component.ts new file mode 100644 index 00000000..7969cdd2 --- /dev/null +++ b/libs/app/explore/ui/src/searching-modal/searching-modal.component.ts @@ -0,0 +1,43 @@ +import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; +import { Logout } from '@fridge-to-plate/app/auth/utils'; +import { ExploreState } from '@fridge-to-plate/app/explore/data-access'; +import { IPreferences, UpdatePreferences } from '@fridge-to-plate/app/preferences/utils'; +import { Select, Store } from '@ngxs/store'; +import { Observable, debounceTime, distinctUntilChanged, filter, fromEvent, take, tap } from 'rxjs'; +import { Navigate } from "@ngxs/router-plugin"; +import { RetrieveProfile } from '@fridge-to-plate/app/profile/utils'; +import { CategorySearch, IExplore, RetrieveRecipe } from '@fridge-to-plate/app/explore/utils'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'searching-modal', + templateUrl: './searching-modal.component.html', + styleUrls: ['./searching-modal.component.scss'], +}) +export class SearchingModalComponent { + + @Select(ExploreState.getExplore) explore$ !: Observable; + searchText = ""; + result = ""; + + + @Output() newSearchEvent = new EventEmitter(); + + @ViewChild('input') input: ElementRef; + + constructor(private store: Store) { + } + + explorer() { + + fromEvent(this.input.nativeElement, 'keyup') + .pipe( + debounceTime(1100), + distinctUntilChanged(), + tap( (text) => { + this.newSearchEvent.emit(this.searchText) + }) + ).subscribe() + } + +} diff --git a/libs/app/explore/ui/src/test-setup.ts b/libs/app/explore/ui/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/explore/ui/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/explore/ui/tsconfig.json b/libs/app/explore/ui/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/explore/ui/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/explore/ui/tsconfig.lib.json b/libs/app/explore/ui/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/explore/ui/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/explore/ui/tsconfig.spec.json b/libs/app/explore/ui/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/explore/ui/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/explore/utils/.eslintrc.json b/libs/app/explore/utils/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/explore/utils/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/explore/utils/README.md b/libs/app/explore/utils/README.md new file mode 100644 index 00000000..5d81ec9d --- /dev/null +++ b/libs/app/explore/utils/README.md @@ -0,0 +1,7 @@ +# app-explore-utils + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-explore-utils` to execute the unit tests. diff --git a/libs/app/explore/utils/jest.config.ts b/libs/app/explore/utils/jest.config.ts new file mode 100644 index 00000000..16c5884c --- /dev/null +++ b/libs/app/explore/utils/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-explore-utils', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/explore/utils', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/explore/utils/project.json b/libs/app/explore/utils/project.json new file mode 100644 index 00000000..cc36f747 --- /dev/null +++ b/libs/app/explore/utils/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-explore-utils", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/explore/utils/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/explore/utils/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/explore/utils/**/*.ts", + "libs/app/explore/utils/**/*.html" + ] + } + } + } +} diff --git a/libs/app/explore/utils/src/explore.actions.ts b/libs/app/explore/utils/src/explore.actions.ts new file mode 100644 index 00000000..ff2566e6 --- /dev/null +++ b/libs/app/explore/utils/src/explore.actions.ts @@ -0,0 +1,19 @@ +import { IProfile } from "@fridge-to-plate/app/profile/utils"; +import { IRecipe } from "@fridge-to-plate/app/recipe/utils"; +import { IExplore } from "@fridge-to-plate/app/explore/utils"; + + +export class RetrieveProfile { + static readonly type = '[Profile] RetrieveProfile'; + constructor(public readonly username: string) {} +} + +export class RetrieveRecipe { + static readonly type = '[Recipe] RetrieveRecipe'; + constructor(public readonly recipename: string) {} +} + +export class CategorySearch { + static readonly type = '[Explore] CategorySearch'; + constructor(public readonly search: IExplore) {} +} \ No newline at end of file diff --git a/libs/app/explore/utils/src/index.ts b/libs/app/explore/utils/src/index.ts new file mode 100644 index 00000000..c13a97ac --- /dev/null +++ b/libs/app/explore/utils/src/index.ts @@ -0,0 +1,2 @@ +export * from './explore.actions'; +export * from './interfaces'; diff --git a/libs/app/explore/utils/src/interfaces/explore.interface.ts b/libs/app/explore/utils/src/interfaces/explore.interface.ts new file mode 100644 index 00000000..1331605a --- /dev/null +++ b/libs/app/explore/utils/src/interfaces/explore.interface.ts @@ -0,0 +1,6 @@ +export interface IExplore { + type: string; + search: string; + tags: string[]; + difficulty: 'Any' | 'Easy' | 'Medium' | 'Hard'; +} diff --git a/libs/app/explore/utils/src/interfaces/index.ts b/libs/app/explore/utils/src/interfaces/index.ts new file mode 100644 index 00000000..d59e9348 --- /dev/null +++ b/libs/app/explore/utils/src/interfaces/index.ts @@ -0,0 +1 @@ +export * from './explore.interface'; \ No newline at end of file diff --git a/libs/app/explore/utils/src/test-setup.ts b/libs/app/explore/utils/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/explore/utils/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/explore/utils/tsconfig.json b/libs/app/explore/utils/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/explore/utils/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/explore/utils/tsconfig.lib.json b/libs/app/explore/utils/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/explore/utils/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/explore/utils/tsconfig.spec.json b/libs/app/explore/utils/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/explore/utils/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/forgot/feature/.eslintrc.json b/libs/app/forgot/feature/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/forgot/feature/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/forgot/feature/README.md b/libs/app/forgot/feature/README.md new file mode 100644 index 00000000..ee511fd8 --- /dev/null +++ b/libs/app/forgot/feature/README.md @@ -0,0 +1,7 @@ +# app-forgot-feature + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-forgot-feature` to execute the unit tests. diff --git a/libs/app/forgot/feature/jest.config.ts b/libs/app/forgot/feature/jest.config.ts new file mode 100644 index 00000000..4ebb0499 --- /dev/null +++ b/libs/app/forgot/feature/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-forgot-feature', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/forgot/feature', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/forgot/feature/project.json b/libs/app/forgot/feature/project.json new file mode 100644 index 00000000..9a18ffd4 --- /dev/null +++ b/libs/app/forgot/feature/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-forgot-feature", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/forgot/feature/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/forgot/feature/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/forgot/feature/**/*.ts", + "libs/app/forgot/feature/**/*.html" + ] + } + } + } +} diff --git a/libs/app/forgot/feature/src/forgot.module.ts b/libs/app/forgot/feature/src/forgot.module.ts new file mode 100644 index 00000000..2c0e0adb --- /dev/null +++ b/libs/app/forgot/feature/src/forgot.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; +import { IonicModule } from '@ionic/angular'; +import { CommonModule } from '@angular/common'; +import { ForgotPage } from './forgot.page'; +import { ForgotRouting } from './forgot.routing'; +import { FormsModule } from '@angular/forms'; +import { ForgotUIModule } from '@fridge-to-plate/app/forgot/ui'; + + + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + ForgotRouting, + IonicModule, + FormsModule, + ForgotUIModule, + ], + declarations: [ForgotPage], +}) +export class ForgotModule {} diff --git a/libs/app/forgot/feature/src/forgot.page.html b/libs/app/forgot/feature/src/forgot.page.html new file mode 100644 index 00000000..37fb7be2 --- /dev/null +++ b/libs/app/forgot/feature/src/forgot.page.html @@ -0,0 +1,25 @@ + + +
+ +
+ +

Forgot Password

+ +
+

Please enter your username to receive your verification code

+
+ +
+ + + + + +
+ +
+ +
+ +
diff --git a/libs/app/forgot/feature/src/forgot.page.scss b/libs/app/forgot/feature/src/forgot.page.scss new file mode 100644 index 00000000..34c9def5 --- /dev/null +++ b/libs/app/forgot/feature/src/forgot.page.scss @@ -0,0 +1,6 @@ +@media only screen and (max-width: 768px) { + + .hidden { + display: none !important; + } + } \ No newline at end of file diff --git a/libs/app/forgot/feature/src/forgot.page.ts b/libs/app/forgot/feature/src/forgot.page.ts new file mode 100644 index 00000000..0d5d8134 --- /dev/null +++ b/libs/app/forgot/feature/src/forgot.page.ts @@ -0,0 +1,30 @@ +import { Component } from "@angular/core"; +import { NgForm } from '@angular/forms'; +import { Forgot } from "@fridge-to-plate/app/auth/utils"; +import { Navigate } from "@ngxs/router-plugin"; +import { Store } from "@ngxs/store"; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: "forgot-page", + templateUrl: "./forgot.page.html", + styleUrls: ["./forgot.page.scss"], +}) +// eslint-disable-next-line @angular-eslint/component-class-suffix +export class ForgotPage { + + username = ""; + password = ""; + + displayConfirm = "none"; + displayVerification = "none"; + + constructor(private store: Store) { } + + onForgot(form: NgForm){ + if (form.valid) { + this.store.dispatch(new Forgot(this.username)); + } + } +} + diff --git a/libs/app/forgot/feature/src/forgot.routing.ts b/libs/app/forgot/feature/src/forgot.routing.ts new file mode 100644 index 00000000..90d72a9f --- /dev/null +++ b/libs/app/forgot/feature/src/forgot.routing.ts @@ -0,0 +1,30 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { ForgotPage } from './forgot.page'; +import { ConfirmModalComponent } from '@fridge-to-plate/app/forgot/ui'; +import { VerificationModalComponent } from '@fridge-to-plate/app/forgot/ui'; + + +const routes: Routes = [ + { + path: '', + pathMatch: 'full', + component: ForgotPage, + }, + { + path: 'confirm', + pathMatch: 'full', + component: ConfirmModalComponent, + }, + { + path: 'verification', + pathMatch: 'full', + component: VerificationModalComponent, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class ForgotRouting {} \ No newline at end of file diff --git a/libs/app/forgot/feature/src/index.ts b/libs/app/forgot/feature/src/index.ts new file mode 100644 index 00000000..86b45a1b --- /dev/null +++ b/libs/app/forgot/feature/src/index.ts @@ -0,0 +1 @@ +export * from './forgot.module'; diff --git a/libs/app/forgot/feature/src/test-setup.ts b/libs/app/forgot/feature/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/forgot/feature/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/forgot/feature/tsconfig.json b/libs/app/forgot/feature/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/forgot/feature/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/forgot/feature/tsconfig.lib.json b/libs/app/forgot/feature/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/forgot/feature/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/forgot/feature/tsconfig.spec.json b/libs/app/forgot/feature/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/forgot/feature/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/forgot/ui/.eslintrc.json b/libs/app/forgot/ui/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/forgot/ui/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/forgot/ui/README.md b/libs/app/forgot/ui/README.md new file mode 100644 index 00000000..5d6b3f09 --- /dev/null +++ b/libs/app/forgot/ui/README.md @@ -0,0 +1,7 @@ +# app-forgot-ui + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-forgot-ui` to execute the unit tests. diff --git a/libs/app/forgot/ui/jest.config.ts b/libs/app/forgot/ui/jest.config.ts new file mode 100644 index 00000000..fb277eec --- /dev/null +++ b/libs/app/forgot/ui/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-forgot-ui', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/forgot/ui', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/forgot/ui/project.json b/libs/app/forgot/ui/project.json new file mode 100644 index 00000000..24b50237 --- /dev/null +++ b/libs/app/forgot/ui/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-forgot-ui", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/forgot/ui/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/forgot/ui/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/forgot/ui/**/*.ts", + "libs/app/forgot/ui/**/*.html" + ] + } + } + } +} diff --git a/libs/app/forgot/ui/src/confirm-modal/confirm-modal.component.html b/libs/app/forgot/ui/src/confirm-modal/confirm-modal.component.html new file mode 100644 index 00000000..fd39b5fb --- /dev/null +++ b/libs/app/forgot/ui/src/confirm-modal/confirm-modal.component.html @@ -0,0 +1,17 @@ + +
+ +
+ +

Password Changed

+ +
+

Please proceed and login into your account

+
+ + + +
+ +
+ \ No newline at end of file diff --git a/libs/app/forgot/ui/src/confirm-modal/confirm-modal.component.scss b/libs/app/forgot/ui/src/confirm-modal/confirm-modal.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/libs/app/forgot/ui/src/confirm-modal/confirm-modal.component.spec.ts b/libs/app/forgot/ui/src/confirm-modal/confirm-modal.component.spec.ts new file mode 100644 index 00000000..72bf8e44 --- /dev/null +++ b/libs/app/forgot/ui/src/confirm-modal/confirm-modal.component.spec.ts @@ -0,0 +1,40 @@ +import { TestBed } from '@angular/core/testing'; +import { ConfirmModalComponent } from './confirm-modal.component'; +import { Store } from '@ngxs/store'; +import { Navigate } from '@ngxs/router-plugin'; + +// Mock the dependencies +const mockStore = { + dispatch: jest.fn(), +}; + +describe('ConfirmModalComponent', () => { + let component: ConfirmModalComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ConfirmModalComponent], + providers: [ + // Provide the mock store + { provide: Store, useValue: mockStore }, + ], + }); + + // Create an instance of the component + component = TestBed.createComponent(ConfirmModalComponent).componentInstance; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should dispatch a Navigate action when calling goLogin', () => { + // Call the goLogin function + component.goLogin(); + + // Check if the store.dispatch method was called with the Navigate action + expect(mockStore.dispatch).toHaveBeenCalledWith(new Navigate(['/login'])); + }); + + // Add more tests as needed for the component's behavior +}); diff --git a/libs/app/forgot/ui/src/confirm-modal/confirm-modal.component.ts b/libs/app/forgot/ui/src/confirm-modal/confirm-modal.component.ts new file mode 100644 index 00000000..5bac02ab --- /dev/null +++ b/libs/app/forgot/ui/src/confirm-modal/confirm-modal.component.ts @@ -0,0 +1,20 @@ +import { Component} from '@angular/core'; +import { Navigate } from '@ngxs/router-plugin'; +import { Store } from '@ngxs/store'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'confirm-modal', + templateUrl: './confirm-modal.component.html', + styleUrls: ['./confirm-modal.component.scss'], +}) + +export class ConfirmModalComponent { + + constructor(private store: Store) { } + + goLogin() { + this.store.dispatch(new Navigate(['/login'])); + } + +} diff --git a/libs/app/forgot/ui/src/forgot.module.ts b/libs/app/forgot/ui/src/forgot.module.ts new file mode 100644 index 00000000..8b3ec4af --- /dev/null +++ b/libs/app/forgot/ui/src/forgot.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { IonicModule } from '@ionic/angular'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ConfirmModalComponent } from './confirm-modal/confirm-modal.component'; +import { VerificationModalComponent } from './verification-modal/verification-modal.component'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + IonicModule, + ], + declarations: [VerificationModalComponent, ConfirmModalComponent], + exports: [VerificationModalComponent, ConfirmModalComponent] +}) +export class ForgotUIModule {} diff --git a/libs/app/forgot/ui/src/index.ts b/libs/app/forgot/ui/src/index.ts new file mode 100644 index 00000000..c87877b0 --- /dev/null +++ b/libs/app/forgot/ui/src/index.ts @@ -0,0 +1,3 @@ +export * from './forgot.module'; +export * from './verification-modal/verification-modal.component'; +export * from './confirm-modal/confirm-modal.component'; \ No newline at end of file diff --git a/libs/app/forgot/ui/src/test-setup.ts b/libs/app/forgot/ui/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/forgot/ui/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/forgot/ui/src/verification-modal/verification-modal.component.html b/libs/app/forgot/ui/src/verification-modal/verification-modal.component.html new file mode 100644 index 00000000..e179a2d6 --- /dev/null +++ b/libs/app/forgot/ui/src/verification-modal/verification-modal.component.html @@ -0,0 +1,28 @@ + +
+ +
+ +

New Password

+ +
+

Please enter the verification code received and your new details

+
+ +
+ + + + + + + + + + +
+ +
+ +
+ \ No newline at end of file diff --git a/libs/app/forgot/ui/src/verification-modal/verification-modal.component.scss b/libs/app/forgot/ui/src/verification-modal/verification-modal.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/libs/app/forgot/ui/src/verification-modal/verification-modal.component.spec.ts b/libs/app/forgot/ui/src/verification-modal/verification-modal.component.spec.ts new file mode 100644 index 00000000..a366663c --- /dev/null +++ b/libs/app/forgot/ui/src/verification-modal/verification-modal.component.spec.ts @@ -0,0 +1,69 @@ +import { TestBed } from '@angular/core/testing'; +import { VerificationModalComponent } from './verification-modal.component'; +import { Store } from '@ngxs/store'; +import { NewPassword } from '@fridge-to-plate/app/auth/utils'; +import { ShowError } from '@fridge-to-plate/app/error/utils'; + +// Mock the dependencies +const mockStore = { + dispatch: jest.fn(), +}; + +describe('VerificationModalComponent', () => { + let component: VerificationModalComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [VerificationModalComponent], + providers: [ + // Provide the mock store + { provide: Store, useValue: mockStore }, + ], + }); + + // Create an instance of the component + component = TestBed.createComponent(VerificationModalComponent).componentInstance; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should dispatch a NewPassword action when all required fields are filled and passwords match', () => { + // Set up component properties + component.verification_code = '123456'; // Replace with your test verification code + component.new_password = 'new_password'; // Replace with your test new password + component.confirm_password = 'new_password'; // Make sure passwords match for this test + + // Call the onVerify function + component.onVerify(); + + // Check if the store.dispatch method was called with the NewPassword action + expect(mockStore.dispatch).toHaveBeenCalledWith(new NewPassword('123456', 'new_password')); + }); + + it('should not dispatch any action when any required field is empty', () => { + // Call the onVerify function without setting any properties + + // Call the onVerify function + component.onVerify(); + + // Check if the store.dispatch method was not called + expect(mockStore.dispatch).not.toHaveBeenCalled(); + }); + + it('should dispatch a ShowError action when passwords do not match', () => { + // Set up component properties + component.verification_code = '123456'; // Replace with your test verification code + component.new_password = 'password1'; // Replace with your test new password + component.confirm_password = 'password2'; // Make sure passwords do not match for this test + + // Call the onVerify function + component.onVerify(); + + // Check if the store.dispatch method was called with the ShowError action + expect(mockStore.dispatch).toHaveBeenCalledWith(new ShowError('Please Enter Matching Passwords')); + }); + + // Add more tests as needed for the component's behavior +}); diff --git a/libs/app/forgot/ui/src/verification-modal/verification-modal.component.ts b/libs/app/forgot/ui/src/verification-modal/verification-modal.component.ts new file mode 100644 index 00000000..984989b3 --- /dev/null +++ b/libs/app/forgot/ui/src/verification-modal/verification-modal.component.ts @@ -0,0 +1,35 @@ +import { Component } from '@angular/core'; +import { NgForm } from '@angular/forms'; +import { NewPassword } from '@fridge-to-plate/app/auth/utils'; +import { ShowError } from '@fridge-to-plate/app/error/utils'; +import { Store } from '@ngxs/store'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'verification-modal', + templateUrl: './verification-modal.component.html', + styleUrls: ['./verification-modal.component.scss'], +}) + +export class VerificationModalComponent { + + constructor(private store: Store) { + } + + verification_code = ""; + new_password = ""; + confirm_password = ""; + + onVerify(){ + + if (this.verification_code != "" && this.new_password != "" && this.confirm_password != "") { + if (this.new_password != this.confirm_password) + this.store.dispatch(new ShowError("Please Enter Matching Passwords")); + + else + this.store.dispatch(new NewPassword(this.verification_code, this.new_password)); + + } + } + +} diff --git a/libs/app/forgot/ui/tsconfig.json b/libs/app/forgot/ui/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/forgot/ui/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/forgot/ui/tsconfig.lib.json b/libs/app/forgot/ui/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/forgot/ui/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/forgot/ui/tsconfig.spec.json b/libs/app/forgot/ui/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/forgot/ui/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/home/data-access/.eslintrc.json b/libs/app/home/data-access/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/home/data-access/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/home/data-access/README.md b/libs/app/home/data-access/README.md new file mode 100644 index 00000000..dd4c0b18 --- /dev/null +++ b/libs/app/home/data-access/README.md @@ -0,0 +1,7 @@ +# app-home-data-access + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-home-data-access` to execute the unit tests. diff --git a/libs/app/home/data-access/jest.config.ts b/libs/app/home/data-access/jest.config.ts new file mode 100644 index 00000000..fd2755e9 --- /dev/null +++ b/libs/app/home/data-access/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-home-data-access', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/home/data-access', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/home/data-access/project.json b/libs/app/home/data-access/project.json new file mode 100644 index 00000000..832bf819 --- /dev/null +++ b/libs/app/home/data-access/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-home-data-access", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/home/data-access/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/home/data-access/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/home/data-access/**/*.ts", + "libs/app/home/data-access/**/*.html" + ] + } + } + } +} diff --git a/libs/app/home/data-access/src/home.module.ts b/libs/app/home/data-access/src/home.module.ts new file mode 100644 index 00000000..738de518 --- /dev/null +++ b/libs/app/home/data-access/src/home.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NgxsModule } from '@ngxs/store'; +import { HomeState } from './home.state'; + +@NgModule({ + imports: [ + CommonModule, + NgxsModule.forFeature([HomeState]), + ], +}) +export class HomeDataAccessModule {} diff --git a/libs/app/home/data-access/src/home.state.ts b/libs/app/home/data-access/src/home.state.ts new file mode 100644 index 00000000..b44e7eb3 --- /dev/null +++ b/libs/app/home/data-access/src/home.state.ts @@ -0,0 +1,52 @@ +import { IRecipe } from '@fridge-to-plate/app/recipe/utils'; +import { Action, Selector, State, StateContext, Store } from '@ngxs/store'; +import { Injectable } from '@angular/core'; +import { RetrieveFeaturedRecipes } from '../../utils/src/home.actions'; +import { ExploreAPI } from '@fridge-to-plate/app/explore/data-access'; +import { IExplore } from '@fridge-to-plate/app/explore/utils'; +import { ShowError } from '@fridge-to-plate/app/error/utils'; + +export interface HomeStateModel { + featuredRecipes: IRecipe[] | null; +} + +@State({ + name: 'home', + defaults: { + featuredRecipes: [] + } +}) +@Injectable() +export class HomeState { + + constructor(private api: ExploreAPI, private store: Store) {} + + @Selector() + static getFeaturedRecipes(state: HomeStateModel) { + return state.featuredRecipes; + } + + + @Action(RetrieveFeaturedRecipes) + async getRecipe({ setState }: StateContext, { meal }: RetrieveFeaturedRecipes) { + + const explore : IExplore = { + type: meal, + search: "", + tags: [], + difficulty: "Any", + }; + + (await this.api.searchCategory(explore)).subscribe({ + next: data => { + setState({ + featuredRecipes: data + }); + }, + error: error => { + this.store.dispatch(new ShowError('Failed to retrieved featured recipes')); + } + }); + + } +} \ No newline at end of file diff --git a/libs/app/home/data-access/src/index.ts b/libs/app/home/data-access/src/index.ts new file mode 100644 index 00000000..906c5d32 --- /dev/null +++ b/libs/app/home/data-access/src/index.ts @@ -0,0 +1,2 @@ +export * from './home.module'; +export * from './home.state'; \ No newline at end of file diff --git a/libs/app/home/data-access/src/test-setup.ts b/libs/app/home/data-access/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/home/data-access/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/home/data-access/tsconfig.json b/libs/app/home/data-access/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/home/data-access/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/home/data-access/tsconfig.lib.json b/libs/app/home/data-access/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/home/data-access/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/home/data-access/tsconfig.spec.json b/libs/app/home/data-access/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/home/data-access/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/home/feature/.eslintrc.json b/libs/app/home/feature/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/home/feature/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/home/feature/README.md b/libs/app/home/feature/README.md new file mode 100644 index 00000000..a1c9bf8a --- /dev/null +++ b/libs/app/home/feature/README.md @@ -0,0 +1,7 @@ +# app-home-feature + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-home-feature` to execute the unit tests. diff --git a/libs/app/home/feature/jest.config.ts b/libs/app/home/feature/jest.config.ts new file mode 100644 index 00000000..e244b87b --- /dev/null +++ b/libs/app/home/feature/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-home-feature', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/home/feature', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/home/feature/project.json b/libs/app/home/feature/project.json new file mode 100644 index 00000000..cf9e9839 --- /dev/null +++ b/libs/app/home/feature/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-home-feature", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/home/feature/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/home/feature/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/home/feature/**/*.ts", + "libs/app/home/feature/**/*.html" + ] + } + } + } +} diff --git a/libs/app/home/feature/src/home.module.ts b/libs/app/home/feature/src/home.module.ts new file mode 100644 index 00000000..10b016c1 --- /dev/null +++ b/libs/app/home/feature/src/home.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { HomePage } from './home.page'; +import { HomeRouting } from './home.routing' +import { RecipeUIModule } from '@fridge-to-plate/app/recipe/ui'; +import { HomeDataAccessModule } from '../../data-access/src/home.module'; + +@NgModule({ + imports: [ + CommonModule, + HomeRouting, + RecipeUIModule, + HomeDataAccessModule + ], + declarations: [HomePage], +}) +export class HomeModule {} diff --git a/libs/app/home/feature/src/home.page.css b/libs/app/home/feature/src/home.page.css new file mode 100644 index 00000000..e69de29b diff --git a/libs/app/home/feature/src/home.page.html b/libs/app/home/feature/src/home.page.html new file mode 100644 index 00000000..569d6284 --- /dev/null +++ b/libs/app/home/feature/src/home.page.html @@ -0,0 +1,40 @@ +
+ +
+

{{messageHeader}}

+
+ +
+

+ Looking for some culinary inspiration based on the ingredients you currently have? +

+
+ +
+
+ + + +
+

Featured Recipes

+
+
+ +
+
+
+ +
+ + + diff --git a/libs/app/home/feature/src/home.page.spec.ts b/libs/app/home/feature/src/home.page.spec.ts new file mode 100644 index 00000000..a303fead --- /dev/null +++ b/libs/app/home/feature/src/home.page.spec.ts @@ -0,0 +1,78 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HomePage } from './home.page'; +import { Router } from '@angular/router'; +import { NgxsModule } from '@ngxs/store'; + +describe('HomePage', () => { + let component: HomePage; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NgxsModule.forRoot()], + declarations: [HomePage], + }).compileComponents(); + + fixture = TestBed.createComponent(HomePage); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should navigate to RecommendPage', () => { + const spy = jest.spyOn(TestBed.inject(Router), 'navigate'); + component.goToRecommend(); + expect(spy).toHaveBeenCalledWith(['/recommend']) + }); + + it('should be meal time for breakfast', () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('March 13, 10:04:20')); + + fixture = TestBed.createComponent(HomePage); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.mealType).toBe('Breakfast'); + expect(component.messageHeader).toBe(`Good morning! What's on the menu for breakfast?`); + }); + + it('should be meal time for lunch', () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('March 13, 13:04:20')); + + fixture = TestBed.createComponent(HomePage); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.mealType).toBe('Lunch'); + expect(component.messageHeader).toBe(`Hungry for a delicious lunch? Let's get cooking!`); + }); + + it('should be meal time for dinner', () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('March 13, 19:04:20')); + + fixture = TestBed.createComponent(HomePage); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.mealType).toBe('Dinner'); + expect(component.messageHeader).toBe(`It's dinner time! Enjoy a tasty meal tonight.`); + }); + + it('should be meal time for snack', () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('March 13, 23:04:20')); + + fixture = TestBed.createComponent(HomePage); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.mealType).toBe('Snack'); + expect(component.messageHeader).toBe(`Time for a snack! What do you feel like making?`); + }); +}); diff --git a/libs/app/home/feature/src/home.page.ts b/libs/app/home/feature/src/home.page.ts new file mode 100644 index 00000000..695ea98d --- /dev/null +++ b/libs/app/home/feature/src/home.page.ts @@ -0,0 +1,52 @@ +import { Component, NgZone } from '@angular/core'; +import { ProfileState } from '@fridge-to-plate/app/profile/data-access'; +import { IProfile } from '@fridge-to-plate/app/profile/utils'; +import { Select, Store } from '@ngxs/store'; +import { Observable } from 'rxjs'; +import { IRecipe } from '@fridge-to-plate/app/recipe/utils'; +import { RecipeState } from '@fridge-to-plate/app/recipe/data-access'; +import { Router } from '@angular/router'; +import { HomeState } from '../../data-access/src/home.state'; +import { RetrieveFeaturedRecipes } from '../../utils/src/home.actions'; + +@Component({ + selector: 'fridge-to-plate-home', + templateUrl: './home.page.html', + styleUrls: ['./home.page.css'], +}) +// eslint-disable-next-line @angular-eslint/component-class-suffix +export class HomePage { + mealType :'Breakfast' | 'Lunch' | 'Dinner' | 'Snack' = 'Breakfast' + messageHeader = ''; + @Select(ProfileState.getProfile) profile$ !: Observable; + @Select(HomeState.getFeaturedRecipes) featuredRecipes$ !: Observable; + + constructor(private readonly router: Router, private readonly ngZone: NgZone, private store: Store){ + const currentTime = new Date(); + const currentHour = currentTime.getHours(); + if (currentHour >= 5 && currentHour < 12) { + this.mealType = 'Breakfast'; + this.messageHeader = `Good morning! What's on the menu for breakfast?`; + } + else if (currentHour >= 12 && currentHour < 17) { + this.mealType = 'Lunch'; + this.messageHeader = `Hungry for a delicious lunch? Let's get cooking!`; + } + else if (currentHour >= 17 && currentHour < 21) { + this.mealType = 'Dinner'; + this.messageHeader = `It's dinner time! Enjoy a tasty meal tonight.`; + } + else { + this.mealType = 'Snack'; + this.messageHeader = `Time for a snack! What do you feel like making?`; + } + + this.store.dispatch(new RetrieveFeaturedRecipes("lunch")); + } + + goToRecommend(): void { + this.ngZone.run(() => { + this.router.navigate(['/recommend']); + }); + } +} diff --git a/libs/app/home/feature/src/home.routing.ts b/libs/app/home/feature/src/home.routing.ts new file mode 100644 index 00000000..913a897f --- /dev/null +++ b/libs/app/home/feature/src/home.routing.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { HomePage } from './home.page'; + + +const routes: Routes = [ + { + path: '', + pathMatch: 'full', + component: HomePage, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class HomeRouting {} \ No newline at end of file diff --git a/libs/app/home/feature/src/index.ts b/libs/app/home/feature/src/index.ts new file mode 100644 index 00000000..ca0d896a --- /dev/null +++ b/libs/app/home/feature/src/index.ts @@ -0,0 +1 @@ +export * from './home.module'; diff --git a/libs/app/home/feature/src/test-setup.ts b/libs/app/home/feature/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/home/feature/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/home/feature/tsconfig.json b/libs/app/home/feature/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/home/feature/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/home/feature/tsconfig.lib.json b/libs/app/home/feature/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/home/feature/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/home/feature/tsconfig.spec.json b/libs/app/home/feature/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/home/feature/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/home/utils/.eslintrc.json b/libs/app/home/utils/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/home/utils/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/home/utils/README.md b/libs/app/home/utils/README.md new file mode 100644 index 00000000..ec4d108d --- /dev/null +++ b/libs/app/home/utils/README.md @@ -0,0 +1,7 @@ +# app-home-utils + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-home-utils` to execute the unit tests. diff --git a/libs/app/home/utils/jest.config.ts b/libs/app/home/utils/jest.config.ts new file mode 100644 index 00000000..bd2b4371 --- /dev/null +++ b/libs/app/home/utils/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-home-utils', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/home/utils', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/home/utils/project.json b/libs/app/home/utils/project.json new file mode 100644 index 00000000..136f45a1 --- /dev/null +++ b/libs/app/home/utils/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-home-utils", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/home/utils/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/home/utils/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/home/utils/**/*.ts", + "libs/app/home/utils/**/*.html" + ] + } + } + } +} diff --git a/libs/app/home/utils/src/home.actions.ts b/libs/app/home/utils/src/home.actions.ts new file mode 100644 index 00000000..4f3fe0da --- /dev/null +++ b/libs/app/home/utils/src/home.actions.ts @@ -0,0 +1,4 @@ +export class RetrieveFeaturedRecipes { + static readonly type = '[Home] RetrieveFeaturedRecipes'; + constructor(public readonly meal: string) {} +} \ No newline at end of file diff --git a/libs/app/home/utils/src/index.ts b/libs/app/home/utils/src/index.ts new file mode 100644 index 00000000..64a14476 --- /dev/null +++ b/libs/app/home/utils/src/index.ts @@ -0,0 +1 @@ +export * from './home.actions'; \ No newline at end of file diff --git a/libs/app/home/utils/src/test-setup.ts b/libs/app/home/utils/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/home/utils/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/home/utils/tsconfig.json b/libs/app/home/utils/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/home/utils/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/home/utils/tsconfig.lib.json b/libs/app/home/utils/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/home/utils/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/home/utils/tsconfig.spec.json b/libs/app/home/utils/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/home/utils/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/ingredient/ui/src/ingredient-card/ingredient-card.component.html b/libs/app/ingredient/ui/src/ingredient-card/ingredient-card.component.html index a954113d..269f69b4 100644 --- a/libs/app/ingredient/ui/src/ingredient-card/ingredient-card.component.html +++ b/libs/app/ingredient/ui/src/ingredient-card/ingredient-card.component.html @@ -1,8 +1,12 @@
-
-
-

{{ingredient.name }} {{ ingredient.amount }}

+
+
+

{{ingredient.name }}

+

{{ ingredient.amount }} {{ ingredient.unit }}

- + + +
\ No newline at end of file diff --git a/libs/app/ingredient/ui/src/ingredient-card/ingredient-card.component.spec.ts b/libs/app/ingredient/ui/src/ingredient-card/ingredient-card.component.spec.ts index b8d90fb7..61ae8277 100644 --- a/libs/app/ingredient/ui/src/ingredient-card/ingredient-card.component.spec.ts +++ b/libs/app/ingredient/ui/src/ingredient-card/ingredient-card.component.spec.ts @@ -7,11 +7,10 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'; describe('IngredientCardComponent', () => { let component: IngredientCardComponent; let fixture: ComponentFixture; - let testIngredient: IIngredient; - - testIngredient = { - ingredientId: "test-id", - name: "Carrot" + const testIngredient: IIngredient = { + name: "Carrot", + unit: "mg", + amount: 15, }; beforeEach(async () => { diff --git a/libs/app/ingredient/ui/src/ingredient-card/ingredient-card.component.ts b/libs/app/ingredient/ui/src/ingredient-card/ingredient-card.component.ts index a219652b..4747c212 100644 --- a/libs/app/ingredient/ui/src/ingredient-card/ingredient-card.component.ts +++ b/libs/app/ingredient/ui/src/ingredient-card/ingredient-card.component.ts @@ -1,12 +1,14 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { IIngredient } from '@fridge-to-plate/app/ingredient/utils'; @Component({ + // eslint-disable-next-line @angular-eslint/component-selector selector: 'ingredient-card', templateUrl: './ingredient-card.component.html', styleUrls: ['./ingredient-card.component.scss'], }) export class IngredientCardComponent { - @Input() ingredient : any; + @Input() ingredient !: IIngredient; @Output() removeEvent: EventEmitter = new EventEmitter(); remove() { diff --git a/libs/app/ingredient/utils/src/interfaces/ingredient.interface.ts b/libs/app/ingredient/utils/src/interfaces/ingredient.interface.ts index 1f4d6c81..188d6855 100644 --- a/libs/app/ingredient/utils/src/interfaces/ingredient.interface.ts +++ b/libs/app/ingredient/utils/src/interfaces/ingredient.interface.ts @@ -1,10 +1,5 @@ export interface IIngredient { - ingredientId?: string; name: string; - tags?: string[]; -} - -export interface IQuantityIngredient extends IIngredient { - quantity: number; - scale: string; + amount: number; + unit: string; } diff --git a/libs/app/login/feature/src/login.page.html b/libs/app/login/feature/src/login.page.html index 2595b96d..e87d02c1 100644 --- a/libs/app/login/feature/src/login.page.html +++ b/libs/app/login/feature/src/login.page.html @@ -1,33 +1,37 @@ -
+
-
+
-

Hey,
Welcome Back.

+

Hey,
Welcome Back.

-

Do not have an account? Create

+

Do not have an account? Create

- +
-

Forgot Password? Reset

+

Forgot Password? Reset

- -
+ + + +
diff --git a/libs/app/login/feature/src/login.page.ts b/libs/app/login/feature/src/login.page.ts index efdd42ef..f794c6e8 100644 --- a/libs/app/login/feature/src/login.page.ts +++ b/libs/app/login/feature/src/login.page.ts @@ -1,93 +1,47 @@ -import { Component, OnInit } from "@angular/core"; +import { Component } from "@angular/core"; import { NgForm } from '@angular/forms'; -import { Router } from '@angular/router'; -import { AuthenticationDetails, CognitoUser, CognitoUserPool } from 'amazon-cognito-identity-js'; -import { CognitoIdentityCredentials } from "aws-sdk"; -declare let AWS: any; -//import { environment } from 'src/environments/environment'; +import { Login } from "@fridge-to-plate/app/auth/utils"; +import { RetrievePreferences } from "@fridge-to-plate/app/preferences/utils"; +import { RetrieveProfile } from "@fridge-to-plate/app/profile/utils"; +import { GetUpdatedRecommendation } from "@fridge-to-plate/app/recommend/utils"; +import { ActionsExecuting, actionsExecuting } from "@ngxs-labs/actions-executing"; +import { Navigate } from "@ngxs/router-plugin"; +import { Select, Store } from "@ngxs/store"; +import { Observable } from "rxjs"; -interface formDataInterface { - "username": string; - "password": string; - [key: string]: string; -}; @Component({ + // eslint-disable-next-line @angular-eslint/component-selector selector: "login-page", templateUrl: "./login.page.html", styleUrls: ["./login.page.scss"], }) -export class LoginPage implements OnInit { +// eslint-disable-next-line @angular-eslint/component-class-suffix +export class LoginPage { - isLoading: boolean = false; - email_address: string = ""; - password: string = ""; + username = ""; + password = ""; + @Select(actionsExecuting([Login, RetrievePreferences, RetrieveProfile, GetUpdatedRecommendation])) busy$ !: Observable; - constructor(private router: Router) { } + constructor(private store: Store) { } onSignIn(form: NgForm){ + if (form.valid) { - this.isLoading = true; - let authenticationDetails = new AuthenticationDetails({ - Username: this.email_address, - Password: this.password, - }); - const poolData = { - // UserPoolId: environment.cognitoUserPoolId, // Your user pool id here - // ClientId: environment.cognitoAppClientId // Your client id here - UserPoolId: "temp", // Your user pool id here - ClientId: "temp" - }; - - let userPool = new CognitoUserPool(poolData); - let userData = { Username: this.email_address, Pool: userPool }; - var cognitoUser = new CognitoUser(userData); - cognitoUser.authenticateUser(authenticationDetails, { - onSuccess: (result) => { - this.router.navigate(["profile"]) - }, - onFailure: (err) => { - alert(err.message || JSON.stringify(err)); - this.isLoading = false; - }, - }); + this.store.dispatch(new Login(this.username, this.password)); } } - - - ngOnInit(): void {} - login() { - alert("Resetting..."); - } - reset() { - alert("Resetting..."); + this.store.dispatch(new Navigate(['/forgot'])); } - + create() { - alert("Creating Account..."); - this.router.navigate(["/signup"]) + this.store.dispatch(new Navigate(['/signup'])); } guest() { - const credentials = new CognitoIdentityCredentials({ - IdentityPoolId: "temp", - RoleArn: 'temp', - //LoginId: 'example@gmail.com' - }); - - AWS.config.region = "eu-west-3"; - AWS.config.credentials = credentials; - - credentials.get((err: any) => { - if (err) { - alert(err); - console.log('Authentication failed:', err); - } else { - this.router.navigate(['/profile']); - } - }); + this.store.dispatch(new Navigate(['/recommend'])); } } diff --git a/libs/app/login/ui/.eslintrc.json b/libs/app/login/ui/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/login/ui/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/login/ui/README.md b/libs/app/login/ui/README.md new file mode 100644 index 00000000..34782659 --- /dev/null +++ b/libs/app/login/ui/README.md @@ -0,0 +1,7 @@ +# app-login-ui + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-login-ui` to execute the unit tests. diff --git a/libs/app/login/ui/jest.config.ts b/libs/app/login/ui/jest.config.ts new file mode 100644 index 00000000..fd9bbc3d --- /dev/null +++ b/libs/app/login/ui/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-login-ui', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/login/ui', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/login/ui/project.json b/libs/app/login/ui/project.json new file mode 100644 index 00000000..54e47e3d --- /dev/null +++ b/libs/app/login/ui/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-login-ui", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/login/ui/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/login/ui/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/login/ui/**/*.ts", + "libs/app/login/ui/**/*.html" + ] + } + } + } +} diff --git a/libs/app/login/ui/src/forgot-modal/forgot-modal.component.html b/libs/app/login/ui/src/forgot-modal/forgot-modal.component.html new file mode 100644 index 00000000..e69de29b diff --git a/libs/app/login/ui/src/forgot-modal/forgot-modal.component.scss b/libs/app/login/ui/src/forgot-modal/forgot-modal.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/libs/app/login/ui/src/forgot-modal/forgot-modal.component.ts b/libs/app/login/ui/src/forgot-modal/forgot-modal.component.ts new file mode 100644 index 00000000..96379caa --- /dev/null +++ b/libs/app/login/ui/src/forgot-modal/forgot-modal.component.ts @@ -0,0 +1,19 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { ShowError } from '@fridge-to-plate/app/error/utils'; +import { Store } from '@ngxs/store'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'forgot-modal', + templateUrl: './forgot-modal.component.html', + styleUrls: ['./forgot-modal.component.scss'], +}) + +export class ForgotModalComponent { + + constructor(private store: Store) { + } + + + +} diff --git a/libs/app/login/ui/src/index.ts b/libs/app/login/ui/src/index.ts new file mode 100644 index 00000000..45d6901b --- /dev/null +++ b/libs/app/login/ui/src/index.ts @@ -0,0 +1 @@ +export * from './login.module'; \ No newline at end of file diff --git a/libs/app/login/ui/src/login.module.ts b/libs/app/login/ui/src/login.module.ts new file mode 100644 index 00000000..277340db --- /dev/null +++ b/libs/app/login/ui/src/login.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ForgotModalComponent } from './forgot-modal/forgot-modal.component'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + ], + declarations: [ForgotModalComponent], + exports: [ForgotModalComponent] +}) +export class LoginUIModule {} diff --git a/libs/app/login/ui/src/test-setup.ts b/libs/app/login/ui/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/login/ui/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/login/ui/tsconfig.json b/libs/app/login/ui/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/login/ui/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/login/ui/tsconfig.lib.json b/libs/app/login/ui/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/login/ui/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/login/ui/tsconfig.spec.json b/libs/app/login/ui/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/login/ui/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/login/utils/.eslintrc.json b/libs/app/login/utils/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/login/utils/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/login/utils/README.md b/libs/app/login/utils/README.md new file mode 100644 index 00000000..137084ab --- /dev/null +++ b/libs/app/login/utils/README.md @@ -0,0 +1,7 @@ +# app-login-utils + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-login-utils` to execute the unit tests. diff --git a/libs/app/login/utils/jest.config.ts b/libs/app/login/utils/jest.config.ts new file mode 100644 index 00000000..40589da6 --- /dev/null +++ b/libs/app/login/utils/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-login-utils', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/login/utils', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/login/utils/project.json b/libs/app/login/utils/project.json new file mode 100644 index 00000000..3f2cb80f --- /dev/null +++ b/libs/app/login/utils/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-login-utils", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/login/utils/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/login/utils/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/login/utils/**/*.ts", + "libs/app/login/utils/**/*.html" + ] + } + } + } +} diff --git a/libs/app/login/utils/src/index.ts b/libs/app/login/utils/src/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/libs/app/login/utils/src/test-setup.ts b/libs/app/login/utils/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/login/utils/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/login/utils/tsconfig.json b/libs/app/login/utils/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/login/utils/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/login/utils/tsconfig.lib.json b/libs/app/login/utils/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/login/utils/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/login/utils/tsconfig.spec.json b/libs/app/login/utils/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/login/utils/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/meal-plan/data-access/src/index.ts b/libs/app/meal-plan/data-access/src/index.ts new file mode 100644 index 00000000..cb68175a --- /dev/null +++ b/libs/app/meal-plan/data-access/src/index.ts @@ -0,0 +1,2 @@ +export * from './meal-plan.module'; +export * from './meal-plan.api'; diff --git a/libs/app/meal-plan/data-access/src/meal-plan.api.ts b/libs/app/meal-plan/data-access/src/meal-plan.api.ts new file mode 100644 index 00000000..fd6159e9 --- /dev/null +++ b/libs/app/meal-plan/data-access/src/meal-plan.api.ts @@ -0,0 +1,25 @@ +import { HttpClient } from "@angular/common/http"; +import { Injectable } from "@angular/core"; +import { environment } from "@fridge-to-plate/app/environments/utils"; +import { IMealPlan } from "@fridge-to-plate/app/meal-plan/utils"; +import { Store } from "@ngxs/store"; +import { ShowError } from "@fridge-to-plate/app/error/utils"; + +@Injectable({ + providedIn: 'root' + }) +export class MealPlanAPI { + + private baseUrl = environment.API_URL + '/meal-plans'; + + constructor( private http: HttpClient, private store: Store ){ } + + saveMealPlan(mealPlan: IMealPlan){ + const url = this.baseUrl + '/save'; + return this.http.post(url, mealPlan).subscribe({ + error: error => { + this.store.dispatch(new ShowError('Error occured when updating meal plan')); + } + }); + } +} \ No newline at end of file diff --git a/libs/app/meal-plan/data-access/src/meal-plan.module.ts b/libs/app/meal-plan/data-access/src/meal-plan.module.ts new file mode 100644 index 00000000..1a420aad --- /dev/null +++ b/libs/app/meal-plan/data-access/src/meal-plan.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@NgModule({ + imports: [ + CommonModule, + ], +}) +export class MealPlanDataAccessModule {} diff --git a/libs/app/meal-plan/utils/.eslintrc.json b/libs/app/meal-plan/utils/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/meal-plan/utils/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/meal-plan/utils/README.md b/libs/app/meal-plan/utils/README.md new file mode 100644 index 00000000..b987de94 --- /dev/null +++ b/libs/app/meal-plan/utils/README.md @@ -0,0 +1,7 @@ +# app-meal-plan-utils + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-meal-plan-utils` to execute the unit tests. diff --git a/libs/app/meal-plan/utils/jest.config.ts b/libs/app/meal-plan/utils/jest.config.ts new file mode 100644 index 00000000..40d12e3c --- /dev/null +++ b/libs/app/meal-plan/utils/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-meal-plan-utils', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/meal-plan/utils', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/meal-plan/utils/project.json b/libs/app/meal-plan/utils/project.json new file mode 100644 index 00000000..930f0dc8 --- /dev/null +++ b/libs/app/meal-plan/utils/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-meal-plan-utils", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/meal-plan/utils/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/meal-plan/utils/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/meal-plan/utils/**/*.ts", + "libs/app/meal-plan/utils/**/*.html" + ] + } + } + } +} diff --git a/libs/app/meal-plan/utils/src/index.ts b/libs/app/meal-plan/utils/src/index.ts new file mode 100644 index 00000000..95786098 --- /dev/null +++ b/libs/app/meal-plan/utils/src/index.ts @@ -0,0 +1 @@ +export * from './interfaces'; diff --git a/libs/app/meal-plan/utils/src/interfaces/index.ts b/libs/app/meal-plan/utils/src/interfaces/index.ts new file mode 100644 index 00000000..1c68a512 --- /dev/null +++ b/libs/app/meal-plan/utils/src/interfaces/index.ts @@ -0,0 +1 @@ +export * from './meal-plan.interface'; \ No newline at end of file diff --git a/libs/app/meal-plan/utils/src/interfaces/meal-plan.interface.ts b/libs/app/meal-plan/utils/src/interfaces/meal-plan.interface.ts new file mode 100644 index 00000000..b9456dff --- /dev/null +++ b/libs/app/meal-plan/utils/src/interfaces/meal-plan.interface.ts @@ -0,0 +1,12 @@ +// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries +import { IRecipeDesc } from '@fridge-to-plate/app/recipe/utils'; + +export interface IMealPlan { + username: string; + date: string; + breakfast: IRecipeDesc | null; + lunch: IRecipeDesc | null; + dinner: IRecipeDesc | null; + snack: IRecipeDesc | null; +} + diff --git a/libs/app/meal-plan/utils/src/test-setup.ts b/libs/app/meal-plan/utils/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/meal-plan/utils/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/meal-plan/utils/tsconfig.json b/libs/app/meal-plan/utils/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/meal-plan/utils/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/meal-plan/utils/tsconfig.lib.json b/libs/app/meal-plan/utils/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/meal-plan/utils/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/meal-plan/utils/tsconfig.spec.json b/libs/app/meal-plan/utils/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/meal-plan/utils/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/navigation/feature/package.json b/libs/app/navigation/feature/package.json deleted file mode 100644 index f6041ee5..00000000 --- a/libs/app/navigation/feature/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "@fridge-to-plate/app/navigation/feature", - "version": "0.0.1", - "type": "commonjs" -} diff --git a/libs/app/navigation/feature/project.json b/libs/app/navigation/feature/project.json index a7be0f7e..8931685c 100644 --- a/libs/app/navigation/feature/project.json +++ b/libs/app/navigation/feature/project.json @@ -2,27 +2,12 @@ "name": "app-navigation-feature", "$schema": "../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "libs/app/navigation/feature/src", + "prefix": "fridge-to-plate", + "tags": [], "projectType": "library", "targets": { - "build": { - "executor": "@nrwl/js:tsc", - "outputs": ["{options.outputPath}"], - "options": { - "outputPath": "dist/libs/app/navigation/feature", - "main": "libs/app/navigation/feature/src/index.ts", - "tsConfig": "libs/app/navigation/feature/tsconfig.lib.json", - "assets": ["libs/app/navigation/feature/*.md"] - } - }, - "lint": { - "executor": "@nrwl/linter:eslint", - "outputs": ["{options.outputFile}"], - "options": { - "lintFilePatterns": ["libs/app/navigation/feature/**/*.ts"] - } - }, "test": { - "executor": "@nrwl/jest:jest", + "executor": "@nx/jest:jest", "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "options": { "jestConfig": "libs/app/navigation/feature/jest.config.ts", @@ -34,7 +19,16 @@ "codeCoverage": true } } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/navigation/feature/**/*.ts", + "libs/app/navigation/feature/**/*.html" + ] + } } - }, - "tags": [] + } } diff --git a/libs/app/navigation/feature/src/app-navigation-feature.spec.ts b/libs/app/navigation/feature/src/app-navigation-feature.spec.ts deleted file mode 100644 index a0283113..00000000 --- a/libs/app/navigation/feature/src/app-navigation-feature.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { appNavigationFeature } from './app-navigation-feature'; - -describe('appNavigationFeature', () => { - it('should work', () => { - expect(appNavigationFeature()).toEqual('app-navigation-feature'); - }); -}); diff --git a/libs/app/navigation/feature/src/app-navigation-feature.ts b/libs/app/navigation/feature/src/app-navigation-feature.ts deleted file mode 100644 index 4fef59c0..00000000 --- a/libs/app/navigation/feature/src/app-navigation-feature.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function appNavigationFeature(): string { - return 'app-navigation-feature'; -} diff --git a/libs/app/navigation/feature/src/navigation.component.html b/libs/app/navigation/feature/src/navigation.component.html index 31f6929e..70dc8365 100644 --- a/libs/app/navigation/feature/src/navigation.component.html +++ b/libs/app/navigation/feature/src/navigation.component.html @@ -1,17 +1,34 @@ - - +
+ + + + + + +
diff --git a/libs/app/navigation/feature/src/navigation.component.scss b/libs/app/navigation/feature/src/navigation.component.scss index 8345f928..6a15ee2c 100644 --- a/libs/app/navigation/feature/src/navigation.component.scss +++ b/libs/app/navigation/feature/src/navigation.component.scss @@ -2,18 +2,66 @@ nav a ion-icon { font-size: 24px; /* default font size */ } -@media (min-width: 640px) { +nav a.active { + color: rgb(202, 64, 5); +} + +.active { + color: rgb(202, 64, 5); + // font-weight: bold; +} + +@media (min-width: 700px) { nav a ion-icon { font-size: 32px; /* increase font size on screens wider than 640px */ } } -@media (min-width: 768px) { +@media (min-width: 1024px) { nav a ion-icon { - font-size: 48px; /* increase font size on screens wider than 768px */ + // font-size: 48px; /* increase font size on screens wider than 768px */ + font-size: 15px; } -} -nav a.active { - color: rgb(237, 76, 7); + .tab-size { + font-size: 15px; + } + + // // nav a.active { + // // color: rgb(202, 64, 5); + // // } + + // nav { + // position: fixed; + // top: 0; + // left: 0; + // right: 0; + // z-index: 50; + // padding: 5px; + // } + + // nav a { + // display: inline-block; + // margin: 0 5px; + // padding: 10px; + // color: #333; + // text-decoration: none; + // position: relative; + // } + + // nav a.active { + // color: rgb(202, 64, 5); + // } + + // nav a.active::after { + // content: ""; + // position: absolute; + // left: 50%; + // bottom: 0; + // width: 80%; + // height: 2px; + // background-color: rgb(202, 64, 5); + // transform: translateX(-48%); + // } } + diff --git a/libs/app/navigation/feature/src/navigation.component.ts b/libs/app/navigation/feature/src/navigation.component.ts index 5885d370..b7e0e0ff 100644 --- a/libs/app/navigation/feature/src/navigation.component.ts +++ b/libs/app/navigation/feature/src/navigation.component.ts @@ -1,5 +1,11 @@ -import { Component } from '@angular/core'; +import { Component, EventEmitter, Output } from '@angular/core'; import { Router } from '@angular/router'; +import { Select, Store } from '@ngxs/store'; +import { Navigate } from "@ngxs/router-plugin"; +import { ProfileState } from '@fridge-to-plate/app/profile/data-access'; +import { Observable } from 'rxjs'; +import { IProfile } from '@fridge-to-plate/app/profile/utils'; +// import { ShowError } from '@fridge-to-plate/app/error/utils'; @Component({ selector: 'navigation-bar', @@ -7,9 +13,47 @@ import { Router } from '@angular/router'; styleUrls: ['./navigation.component.scss'], }) export class NavigationBar { - constructor(private router: Router) {} + + @Select(ProfileState.getProfile) profile$ !: Observable; + + constructor(public router: Router, private store: Store) {} isActive(pageName: string) { - return this.router.url.includes(pageName) ? 'active' : ''; + const currentUrl = this.router.url; + const pageUrl = `/${pageName}`; + + return currentUrl === pageUrl ? 'active orange' : ''; + } + + openRecommend() { + this.store.dispatch(new Navigate(['/recommend'])); + } + + openSearch() { + this.store.dispatch(new Navigate(['/search'])); + } + + openCreate() { + this.store.dispatch(new Navigate(['/create'])); + } + + openProfile() { + this.store.dispatch(new Navigate(['/profile'])); + } + + openNotifications() { + this.store.dispatch(new Navigate(['/profile/notifications'])); + } + + openHome() { + this.store.dispatch(new Navigate(['/home'])); + } + + openSignUp() { + this.store.dispatch(new Navigate(['/signup'])); + } + + openLogin() { + this.store.dispatch(new Navigate(['/login'])); } } diff --git a/libs/app/notifications/data-access/.eslintrc.json b/libs/app/notifications/data-access/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/notifications/data-access/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/notifications/data-access/README.md b/libs/app/notifications/data-access/README.md new file mode 100644 index 00000000..c3181d7e --- /dev/null +++ b/libs/app/notifications/data-access/README.md @@ -0,0 +1,7 @@ +# app-notifications-data-access + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-notifications-data-access` to execute the unit tests. diff --git a/libs/app/notifications/data-access/jest.config.ts b/libs/app/notifications/data-access/jest.config.ts new file mode 100644 index 00000000..66ea0184 --- /dev/null +++ b/libs/app/notifications/data-access/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-notifications-data-access', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/notifications/data-access', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/notifications/data-access/project.json b/libs/app/notifications/data-access/project.json new file mode 100644 index 00000000..51ecf22f --- /dev/null +++ b/libs/app/notifications/data-access/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-notifications-data-access", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/notifications/data-access/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/notifications/data-access/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/notifications/data-access/**/*.ts", + "libs/app/notifications/data-access/**/*.html" + ] + } + } + } +} diff --git a/libs/app/notifications/data-access/src/index.ts b/libs/app/notifications/data-access/src/index.ts new file mode 100644 index 00000000..37c374ba --- /dev/null +++ b/libs/app/notifications/data-access/src/index.ts @@ -0,0 +1,4 @@ +export * from './notifications.module'; +export * from './notifications.api'; +export * from '../../utils/notifications.actions'; +export * from './notifications.state'; diff --git a/libs/app/notifications/data-access/src/mock-data/notifications-test.json b/libs/app/notifications/data-access/src/mock-data/notifications-test.json new file mode 100644 index 00000000..e69de29b diff --git a/libs/app/notifications/data-access/src/notifications.api.ts b/libs/app/notifications/data-access/src/notifications.api.ts new file mode 100644 index 00000000..1e2f22c4 --- /dev/null +++ b/libs/app/notifications/data-access/src/notifications.api.ts @@ -0,0 +1,47 @@ +import { BehaviorSubject, Observable } from 'rxjs'; +import { Injectable } from '@angular/core'; +import { + INotification, + INotificationResponse, +} from '@fridge-to-plate/app/notifications/utils'; +import { HttpClient } from '@angular/common/http'; +import { environment } from 'libs/app/environments/utils/src/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class NotificationsApi { + private baseUrl = environment.API_URL + '/notifications'; + + constructor(private http: HttpClient) {} + + getAllNotifications(userId: string): Observable { + const url = `${this.baseUrl}/${userId}`; + + return this.http.get(url); + } + + clearAllNotifications(userId: string): Observable { + const url = `${this.baseUrl}/clear/${userId}`; + + return this.http.delete(url); + } + + clearGeneralNotifications(userId: string): Observable { + const url = `${this.baseUrl}/clear/${userId}/general`; + + return this.http.delete(url); + } + + clearRecommendationNotifications(userId: string): Observable { + const url = `${this.baseUrl}/clear/${userId}/recommendation`; + + return this.http.delete(url); + } + + deleteNotification(notificationId: string): Observable { + const url = `${this.baseUrl}/${notificationId}`; + + return this.http.delete(url); + } +} diff --git a/libs/app/notifications/data-access/src/notifications.module.ts b/libs/app/notifications/data-access/src/notifications.module.ts new file mode 100644 index 00000000..344fd7af --- /dev/null +++ b/libs/app/notifications/data-access/src/notifications.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NotificationsApi } from './notifications.api'; +import { NgxsModule } from '@ngxs/store'; +import { NotificationsState } from './notifications.state'; + +@NgModule({ + imports: [CommonModule, NgxsModule.forFeature([NotificationsState])], + providers: [NotificationsApi], +}) +export class NotificationsDataAccessModule {} diff --git a/libs/app/notifications/data-access/src/notifications.state.ts b/libs/app/notifications/data-access/src/notifications.state.ts new file mode 100644 index 00000000..70f79a06 --- /dev/null +++ b/libs/app/notifications/data-access/src/notifications.state.ts @@ -0,0 +1,104 @@ +import { + Action, + NgxsModule, + Select, + Selector, + State, + StateContext, + Store, +} from '@ngxs/store'; +import { INotification } from '../../utils/src/interfaces'; +import { Injectable } from '@angular/core'; +import { NotificationsApi } from './notifications.api'; +import { + ClearGeneralNotifications, + ClearRecommendationNotifications, + RefreshNotifications, + RefreshRecommendationNotifications, +} from '../../utils/notifications.actions'; +import { ProfileState } from '@fridge-to-plate/app/profile/data-access'; +import { IProfile } from '@fridge-to-plate/app/profile/utils'; +import { Observable, take } from 'rxjs'; +import { TestBed } from '@angular/core/testing'; +import { NotificationsDataAccessModule } from './notifications.module'; + +export interface NotificationsStateModel { + generalNotifications: INotification[] | null; + recommendationNotification: INotification[] | null; +} +@State({ + name: 'notifications', + defaults: { + generalNotifications: [], + recommendationNotification: [], + }, +}) +@Injectable() +export class NotificationsState { + constructor(private notificationsApi: NotificationsApi) {} + + @Select(ProfileState.getProfile) profile$!: Observable; + + @Selector() + static getGeneralNotifications(state: NotificationsStateModel) { + return state.generalNotifications; + } + + @Selector() + static getRecommendationNotifications(state: NotificationsStateModel) { + return state.recommendationNotification; + } + + @Action(RefreshRecommendationNotifications) + refreshNotifications( + ctx: StateContext, + { userId }: RefreshNotifications + ) { + this.profile$.pipe(take(1)).subscribe((loggedInUser) => { + this.notificationsApi + .getAllNotifications(loggedInUser.username) + .pipe(take(1)) + .subscribe((notificationsResponse) => { + ctx.setState({ + generalNotifications: notificationsResponse.general, + recommendationNotification: notificationsResponse.recommendations, + }); + }); + }); + } + + @Action(ClearGeneralNotifications) + clearGeneralNotifications( + ctx: StateContext, + { userId }: ClearGeneralNotifications + ) { + ctx.patchState({ + generalNotifications: [], + }); + + this.notificationsApi.clearGeneralNotifications(userId).subscribe(); + } + + @Action(ClearRecommendationNotifications) + clearRecommendationNotifications( + ctx: StateContext, + { userId }: ClearRecommendationNotifications + ) { + ctx.patchState({ + recommendationNotification: [], + }); + + this.notificationsApi.clearRecommendationNotifications(userId).subscribe(); + } +} + +// let store: Store; +// +// beforeEach(() => { +// TestBed.configureTestingModule({ +// imports: [NgxsModule.forRoot([NotificationsState]), NotificationsDataAccessModule], +// declarations: [] +// }); +// +// store = TestBed.inject(Store); +// }); diff --git a/libs/app/notifications/data-access/src/test-setup.ts b/libs/app/notifications/data-access/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/notifications/data-access/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/notifications/data-access/tsconfig.json b/libs/app/notifications/data-access/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/notifications/data-access/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/notifications/data-access/tsconfig.lib.json b/libs/app/notifications/data-access/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/notifications/data-access/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/notifications/data-access/tsconfig.spec.json b/libs/app/notifications/data-access/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/notifications/data-access/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/notifications/feature/.eslintrc.json b/libs/app/notifications/feature/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/notifications/feature/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/notifications/feature/README.md b/libs/app/notifications/feature/README.md new file mode 100644 index 00000000..01a38ea7 --- /dev/null +++ b/libs/app/notifications/feature/README.md @@ -0,0 +1,7 @@ +# app-notifications-feature + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-notifications-feature` to execute the unit tests. diff --git a/libs/app/notifications/feature/jest.config.ts b/libs/app/notifications/feature/jest.config.ts new file mode 100644 index 00000000..6ec88c02 --- /dev/null +++ b/libs/app/notifications/feature/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-notifications-feature', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/notifications/feature', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/notifications/feature/project.json b/libs/app/notifications/feature/project.json new file mode 100644 index 00000000..00aab3a6 --- /dev/null +++ b/libs/app/notifications/feature/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-notifications-feature", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/notifications/feature/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/notifications/feature/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/notifications/feature/**/*.ts", + "libs/app/notifications/feature/**/*.html" + ] + } + } + } +} diff --git a/libs/app/notifications/feature/src/index.ts b/libs/app/notifications/feature/src/index.ts new file mode 100644 index 00000000..efc59f8e --- /dev/null +++ b/libs/app/notifications/feature/src/index.ts @@ -0,0 +1 @@ +export * from './notification.module'; diff --git a/libs/app/notifications/feature/src/notification.module.ts b/libs/app/notifications/feature/src/notification.module.ts new file mode 100644 index 00000000..61d1dd02 --- /dev/null +++ b/libs/app/notifications/feature/src/notification.module.ts @@ -0,0 +1,24 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NotificationsPage } from './notifications.page'; +import { NzListModule } from 'ng-zorro-antd/list'; +import { NzTabsModule } from 'ng-zorro-antd/tabs'; +import { NotificationsUiModule } from '@fridge-to-plate/app/notifications/ui'; +import { NotificationsRouting } from './notifications.routing'; +import { NavigationBarModule } from '@fridge-to-plate/app/navigation/feature'; +import { NotificationsDataAccessModule } from '@fridge-to-plate/app/notifications/data-access'; + +@NgModule({ + imports: [ + CommonModule, + NzListModule, + NzTabsModule, + NotificationsUiModule, + NotificationsRouting, + NavigationBarModule, + NotificationsDataAccessModule + ], + declarations: [NotificationsPage], + exports: [NotificationsPage], +}) +export class NotificationsFeatureModule {} diff --git a/libs/app/notifications/feature/src/notifications.page.html b/libs/app/notifications/feature/src/notifications.page.html new file mode 100644 index 00000000..b1cf8d55 --- /dev/null +++ b/libs/app/notifications/feature/src/notifications.page.html @@ -0,0 +1,113 @@ +
+ +
+
+ +
+
+

Notifications

+
+
+
+ + + +
+
+ +
+
+
+

{{ notification.userName }}

+
+
+

{{ notification.comment }}

+
+
+
+
+ +
+

No notifications yet

+
+
+
+ + +
+
+ +
+
+
+ {{ notification.userName }} +
+
+

{{ notification.comment }}

+
+
+
+
+ +
+

No notifications yet

+
+
+
+
+
+
diff --git a/libs/app/notifications/feature/src/notifications.page.scss b/libs/app/notifications/feature/src/notifications.page.scss new file mode 100644 index 00000000..3955632c --- /dev/null +++ b/libs/app/notifications/feature/src/notifications.page.scss @@ -0,0 +1,33 @@ +:host { + background: #f8f8f8; + overflow: hidden; + padding: 24px; + display: block; +} + +.card-container ::ng-deep p { + margin: 0; +} +.card-container ::ng-deep > .ant-tabs-card .ant-tabs-content { + height: 120px; + margin-top: -16px; +} +.card-container + ::ng-deep + > .ant-tabs-card + .ant-tabs-content + > .ant-tabs-tabpane { + background: #fff; + padding: 16px; +} +.card-container ::ng-deep > .ant-tabs-card > .ant-tabs-nav::before { + display: none; +} +.card-container ::ng-deep > .ant-tabs-card .ant-tabs-tab { + border-color: transparent; + background: transparent; +} +.card-container ::ng-deep > .ant-tabs-card .ant-tabs-tab-active { + border-color: #fff; + background: #fff; +} diff --git a/libs/app/notifications/feature/src/notifications.page.spec.ts b/libs/app/notifications/feature/src/notifications.page.spec.ts new file mode 100644 index 00000000..372ea1a0 --- /dev/null +++ b/libs/app/notifications/feature/src/notifications.page.spec.ts @@ -0,0 +1,116 @@ +import {NotificationsPage} from "./notifications.page"; +import {ComponentFixture, TestBed} from "@angular/core/testing"; +import { Router, Routes} from "@angular/router"; +import {NotificationsUiModule} from "@fridge-to-plate/app/notifications/ui"; +import {RouterTestingModule} from "@angular/router/testing"; +import { + ClearGeneralNotifications, + ClearRecommendationNotifications, +} from "@fridge-to-plate/app/notifications/data-access"; +import {Location} from "@angular/common"; +import {NgxsModule, State, Store} from "@ngxs/store"; +import {Injectable} from "@angular/core"; +import { Navigate } from "@ngxs/router-plugin"; + +describe('NotificationsPage tests', () => { + + @State({ + name: 'notifications', + defaults: { + generalNotifications: [], + recommendationNotification: [], + } + }) + @Injectable() + class MockNotificationsState {} + + + let component: NotificationsPage; + let fixture: ComponentFixture; + let location: Location; + let store: Store; + let page: any; + const routes: Routes = [ + { + path: 'recipe/:id', + component: NotificationsPage, + }, + ]; + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NotificationsUiModule, RouterTestingModule.withRoutes(routes), NgxsModule.forRoot([MockNotificationsState])], + declarations: [NotificationsPage], + providers: [], + }).compileComponents(); + + fixture = TestBed.createComponent(NotificationsPage); + component = fixture.componentInstance; + fixture.detectChanges(); + location = TestBed.inject(Location); + page = fixture.componentInstance; + store = TestBed.inject(Store); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should navigate back when goBack() is called', () => { + const locationSpy = jest.spyOn(location, 'back'); + component.goBack(); + expect(locationSpy).toHaveBeenCalled(); + }); + + it('should dispatch ClearGeneralNotifications action on clearAllNotifications', () => { + const storeSpy = jest.spyOn(store, 'dispatch'); + page.clearAllNotifications('general'); + + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(storeSpy).toHaveBeenCalledWith(ClearGeneralNotifications); + }); + }); + + it('should dispatch ClearRecommendationNotifications action on clearAllNotifications', () => { + const storeSpy = jest.spyOn(store, 'dispatch'); + page.clearAllNotifications('recommendations'); + + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(storeSpy).toHaveBeenCalledWith(ClearRecommendationNotifications); + }); + }); + + it('should have general notifications in state', () => { + component.generalNotifications$.subscribe( next => { + expect(next).not.toBeFalsy(); + }) + }); + + it('should have recommendation notifications in state', () => { + component.recommendationNotifications$.subscribe( next => { + expect(next).not.toBeFalsy(); + }) + }); + + it('should clear general notifications in state', () => { + page.clearAllNotifications('general') + component.generalNotifications$.subscribe( next => { + expect(next).toBeFalsy(); + }) + }); + + it('should clear recommendation notifications in state', () => { + page.clearAllNotifications('recommendation') + component.recommendationNotifications$.subscribe( next => { + expect(next).toBeFalsy(); + }) + }); + + it('test on notification click navigates to recipe_page', () => { + const storeSpy = jest.spyOn(store, 'dispatch'); + component.onNotificationClick('testRecipeId'); + expect(storeSpy).toHaveBeenCalledWith(new Navigate([`recipe/testRecipeId`])); + }); + +}); diff --git a/libs/app/notifications/feature/src/notifications.page.ts b/libs/app/notifications/feature/src/notifications.page.ts new file mode 100644 index 00000000..6a524353 --- /dev/null +++ b/libs/app/notifications/feature/src/notifications.page.ts @@ -0,0 +1,72 @@ +import { Component } from '@angular/core'; +import { Router } from '@angular/router'; +import { + ClearGeneralNotifications, + ClearRecommendationNotifications, + RefreshRecommendationNotifications, +} from '@fridge-to-plate/app/notifications/data-access'; +import { Observable } from 'rxjs'; +import { Location } from '@angular/common'; +import { + INotification, + INotificationResponse, +} from '@fridge-to-plate/app/notifications/utils'; +import { Select, Store } from '@ngxs/store'; +import { NotificationsState } from '@fridge-to-plate/app/notifications/data-access'; +import { ProfileState } from '@fridge-to-plate/app/profile/data-access'; +import { IProfile } from '@fridge-to-plate/app/profile/utils'; +import { Navigate } from '@ngxs/router-plugin'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'notifications-page', + templateUrl: './notifications.page.html', + styleUrls: ['./notifications.page.scss'], +}) +// eslint-disable-next-line @angular-eslint/component-class-suffix +export class NotificationsPage { + @Select(NotificationsState.getGeneralNotifications) + notifications$!: Observable; + + @Select(NotificationsState.getGeneralNotifications) + generalNotifications$!: Observable; + + @Select(NotificationsState.getRecommendationNotifications) + recommendationNotifications$!: Observable; + + @Select(ProfileState.getProfile) + profile$!: Observable; + + tabs = [ + { category: 'General', count: 8 }, + { category: 'Recommendations', count: 4 }, + ]; + + constructor( + private location: Location, + private router: Router, + private store: Store + ) { + store.dispatch(new RefreshRecommendationNotifications()); + } + + onNotificationClick(recipeId: string): void { + this.store.dispatch(new Navigate([`recipe/${recipeId}`])); + } + + goBack() { + this.location.back(); + } + + clearAllNotifications(clearType: string) { + this.profile$.subscribe((next) => { + if (clearType.includes('general')) { + this.store.dispatch(new ClearGeneralNotifications(next.username)); + } else { + this.store.dispatch( + new ClearRecommendationNotifications(next.username) + ); + } + }); + } +} diff --git a/libs/app/notifications/feature/src/notifications.routing.ts b/libs/app/notifications/feature/src/notifications.routing.ts new file mode 100644 index 00000000..81f8a097 --- /dev/null +++ b/libs/app/notifications/feature/src/notifications.routing.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { NotificationsPage } from './notifications.page'; + +const routes: Routes = [ + { + path: '', + pathMatch: 'full', + component: NotificationsPage + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class NotificationsRouting {} diff --git a/libs/app/notifications/feature/src/test-setup.ts b/libs/app/notifications/feature/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/notifications/feature/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/notifications/feature/tsconfig.json b/libs/app/notifications/feature/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/notifications/feature/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/notifications/feature/tsconfig.lib.json b/libs/app/notifications/feature/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/notifications/feature/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/notifications/feature/tsconfig.spec.json b/libs/app/notifications/feature/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/notifications/feature/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/notifications/ui/.eslintrc.json b/libs/app/notifications/ui/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/notifications/ui/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/notifications/ui/README.md b/libs/app/notifications/ui/README.md new file mode 100644 index 00000000..051c662c --- /dev/null +++ b/libs/app/notifications/ui/README.md @@ -0,0 +1,7 @@ +# app-notifications-ui + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-notifications-ui` to execute the unit tests. diff --git a/libs/app/notifications/ui/jest.config.ts b/libs/app/notifications/ui/jest.config.ts new file mode 100644 index 00000000..1c64ac70 --- /dev/null +++ b/libs/app/notifications/ui/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-notifications-ui', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/notifications/ui', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/notifications/ui/project.json b/libs/app/notifications/ui/project.json new file mode 100644 index 00000000..2bc00dd8 --- /dev/null +++ b/libs/app/notifications/ui/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-notifications-ui", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/notifications/ui/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/notifications/ui/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/notifications/ui/**/*.ts", + "libs/app/notifications/ui/**/*.html" + ] + } + } + } +} diff --git a/libs/app/notifications/ui/src/index.ts b/libs/app/notifications/ui/src/index.ts new file mode 100644 index 00000000..d0ec9fd5 --- /dev/null +++ b/libs/app/notifications/ui/src/index.ts @@ -0,0 +1 @@ +export * from './notifications.module'; diff --git a/libs/app/notifications/ui/src/notifications.module.ts b/libs/app/notifications/ui/src/notifications.module.ts new file mode 100644 index 00000000..5c5434f2 --- /dev/null +++ b/libs/app/notifications/ui/src/notifications.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TabbedComponent } from './tabbed/tabbed.component'; +import { TabComponent } from './tab/tab.component'; + +@NgModule({ + imports: [CommonModule], + declarations: [TabbedComponent, TabComponent], + exports: [TabbedComponent, TabComponent], +}) +export class NotificationsUiModule {} diff --git a/libs/app/notifications/ui/src/tab/tab.component.css b/libs/app/notifications/ui/src/tab/tab.component.css new file mode 100644 index 00000000..8e13c1ef --- /dev/null +++ b/libs/app/notifications/ui/src/tab/tab.component.css @@ -0,0 +1,4 @@ +.pane { + padding: 0 1px; + margin-top: 0.4rem; +} diff --git a/libs/app/notifications/ui/src/tab/tab.component.html b/libs/app/notifications/ui/src/tab/tab.component.html new file mode 100644 index 00000000..517d102a --- /dev/null +++ b/libs/app/notifications/ui/src/tab/tab.component.html @@ -0,0 +1,6 @@ +
+ +
diff --git a/libs/app/notifications/ui/src/tab/tab.component.spec.ts b/libs/app/notifications/ui/src/tab/tab.component.spec.ts new file mode 100644 index 00000000..8fa01f17 --- /dev/null +++ b/libs/app/notifications/ui/src/tab/tab.component.spec.ts @@ -0,0 +1,39 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TabComponent } from './tab.component'; +import { IonicModule } from '@ionic/angular'; + +describe('TabComponent', () => { + let component: TabComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [IonicModule], + declarations: [TabComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TabComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have undefined props', () => { + expect(component.tabName).not.toBeTruthy(); + expect(component.tabCount).not.toBeTruthy(); + expect(component.active).not.toBeTruthy(); + }); + + it('should have prop values as defined', () => { + component.tabName = 'Testing'; + component.tabCount = '5'; + component.active = true; + + expect(component.tabName).toBe('Testing'); + expect(component.tabCount).toBe('5'); + expect(component.active).toBeTruthy(); + }); +}); diff --git a/libs/app/notifications/ui/src/tab/tab.component.ts b/libs/app/notifications/ui/src/tab/tab.component.ts new file mode 100644 index 00000000..12ea72b6 --- /dev/null +++ b/libs/app/notifications/ui/src/tab/tab.component.ts @@ -0,0 +1,12 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'fridge-to-plate-tab', + templateUrl: './tab.component.html', + styleUrls: ['./tab.component.css'], +}) +export class TabComponent { + @Input() tabName: string | undefined; + @Input() tabCount: string | undefined; + @Input() active = false; +} diff --git a/libs/app/notifications/ui/src/tabbed/tabbed.component.css b/libs/app/notifications/ui/src/tabbed/tabbed.component.css new file mode 100644 index 00000000..af0b4412 --- /dev/null +++ b/libs/app/notifications/ui/src/tabbed/tabbed.component.css @@ -0,0 +1,29 @@ +.tabs-container { + height: 60vh; + margin: 2px; +} +.tabs-container .tab { + padding: 5px; + text-align: center; + width: fit-content; + cursor: pointer; + font-weight: 600; + margin-right: 6px; +} + +.tab > p { + margin-right: 10px; +} +.tabs-container .tab:hover { + border-radius: 2px; + opacity: 90%; +} +.tabs-container .tab.active { + color: #c35214; + border-bottom: 2px solid #c35214; +} + +ul { + margin: 0; + padding: 0; +} diff --git a/libs/app/notifications/ui/src/tabbed/tabbed.component.html b/libs/app/notifications/ui/src/tabbed/tabbed.component.html new file mode 100644 index 00000000..478a6ed5 --- /dev/null +++ b/libs/app/notifications/ui/src/tabbed/tabbed.component.html @@ -0,0 +1,27 @@ +
+ +
+ +
+ +
diff --git a/libs/app/notifications/ui/src/tabbed/tabbed.component.spec.ts b/libs/app/notifications/ui/src/tabbed/tabbed.component.spec.ts new file mode 100644 index 00000000..7067b8b7 --- /dev/null +++ b/libs/app/notifications/ui/src/tabbed/tabbed.component.spec.ts @@ -0,0 +1,75 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TabbedComponent } from './tabbed.component'; +import { Component } from '@angular/core'; +import { TabComponent } from '../tab/tab.component'; + +@Component({ + selector: 'fridge-to-plate-test-cmp', + template: ` + + + `, +}) +class TestWrapperComponent {} + +describe('TabbedComponent', () => { + let component: TabbedComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TestWrapperComponent, TabbedComponent, TabComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestWrapperComponent); + component = fixture.debugElement.children[0].componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have only two tabs', () => { + expect(component.tabs.length).toBe(2); + }); + + it('should start on General tab', () => { + const activeTab: TabComponent[] = component.tabs.filter( + (tab) => tab.active + ); + + expect(activeTab.length).toBe(1); + expect(activeTab[0].tabName).toBe('General'); + }); + + it('should navigate to Recommendations tab', () => { + component.selectTab(component.tabs.last); + + const activeTab: TabComponent[] = component.tabs.filter( + (tab) => tab.active + ); + + expect(activeTab.length).toBe(1); + + expect(activeTab[0].tabName).toBe('Recommendations'); + }); + + it('should clear notifications', () => { + jest.spyOn(component.clearNotificationsEvent, 'emit'); + + component.clearNotifications(); + + expect(component.clearNotificationsEvent.emit).toHaveBeenCalledWith( + 'general' + ); + + component.selectTab(component.tabs.last); + + component.clearNotifications(); + + expect(component.clearNotificationsEvent.emit).toHaveBeenCalledWith( + 'recommendations' + ); + }); +}); diff --git a/libs/app/notifications/ui/src/tabbed/tabbed.component.ts b/libs/app/notifications/ui/src/tabbed/tabbed.component.ts new file mode 100644 index 00000000..b54289cf --- /dev/null +++ b/libs/app/notifications/ui/src/tabbed/tabbed.component.ts @@ -0,0 +1,45 @@ +import { + AfterContentInit, + Component, + ContentChildren, + EventEmitter, + Output, + QueryList, +} from '@angular/core'; +import { TabComponent } from '../tab/tab.component'; +@Component({ + selector: 'fridge-to-plate-tabbed', + templateUrl: './tabbed.component.html', + styleUrls: ['./tabbed.component.css'], +}) +export class TabbedComponent implements AfterContentInit { + @ContentChildren(TabComponent) tabs!: QueryList; + + @Output() clearNotificationsEvent = new EventEmitter< + 'general' | 'recommendations' + >(); + + ngAfterContentInit() { + const activeTabs = this.tabs.filter((tab) => tab.active); + + if (activeTabs.length === 0) { + this.selectTab(this.tabs.first); + } + } + + selectTab(tab: TabComponent) { + this.tabs.toArray().forEach((tab) => (tab.active = false)); + tab.active = true; + } + + clearNotifications() { + const currentTab = this.tabs.filter((tab) => tab.active)[0]; + if (currentTab) { + if (currentTab.tabName?.includes('General')) + this.clearNotificationsEvent.emit('general'); + else { + this.clearNotificationsEvent.emit('recommendations'); + } + } + } +} diff --git a/libs/app/notifications/ui/src/test-setup.ts b/libs/app/notifications/ui/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/notifications/ui/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/notifications/ui/tsconfig.json b/libs/app/notifications/ui/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/notifications/ui/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/notifications/ui/tsconfig.lib.json b/libs/app/notifications/ui/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/notifications/ui/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/notifications/ui/tsconfig.spec.json b/libs/app/notifications/ui/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/notifications/ui/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/notifications/utils/.eslintrc.json b/libs/app/notifications/utils/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/notifications/utils/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/notifications/utils/README.md b/libs/app/notifications/utils/README.md new file mode 100644 index 00000000..d737bac3 --- /dev/null +++ b/libs/app/notifications/utils/README.md @@ -0,0 +1,7 @@ +# app-notifications-utils + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-notifications-utils` to execute the unit tests. diff --git a/libs/app/notifications/utils/jest.config.ts b/libs/app/notifications/utils/jest.config.ts new file mode 100644 index 00000000..0b5e8598 --- /dev/null +++ b/libs/app/notifications/utils/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-notifications-utils', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/notifications/utils', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/notifications/utils/notifications.actions.ts b/libs/app/notifications/utils/notifications.actions.ts new file mode 100644 index 00000000..7f37873c --- /dev/null +++ b/libs/app/notifications/utils/notifications.actions.ts @@ -0,0 +1,24 @@ +export class RefreshNotifications { + static readonly type = '[Notifications] Refresh Notifications'; + constructor(public readonly userId: string) {} +} + +export class RefreshGeneralNotifications { + static readonly type = '[Notifications] Refresh General Notifications'; + constructor() {} +} + +export class RefreshRecommendationNotifications { + static readonly type = '[Notifications] Refresh Recommendation Notifications'; + constructor() {} +} + +export class ClearGeneralNotifications { + static readonly type = '[Notifications] Clear General Notifications'; + constructor(public readonly userId: string) {} +} + +export class ClearRecommendationNotifications { + static readonly type = '[Notifications] Clear Recommendation Notifications'; + constructor(public readonly userId: string) {} +} diff --git a/libs/app/notifications/utils/project.json b/libs/app/notifications/utils/project.json new file mode 100644 index 00000000..a2bd746b --- /dev/null +++ b/libs/app/notifications/utils/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-notifications-utils", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/notifications/utils/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/notifications/utils/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/notifications/utils/**/*.ts", + "libs/app/notifications/utils/**/*.html" + ] + } + } + } +} diff --git a/libs/app/notifications/utils/src/index.ts b/libs/app/notifications/utils/src/index.ts new file mode 100644 index 00000000..95786098 --- /dev/null +++ b/libs/app/notifications/utils/src/index.ts @@ -0,0 +1 @@ +export * from './interfaces'; diff --git a/libs/app/notifications/utils/src/interfaces/index.ts b/libs/app/notifications/utils/src/interfaces/index.ts new file mode 100644 index 00000000..1d725d86 --- /dev/null +++ b/libs/app/notifications/utils/src/interfaces/index.ts @@ -0,0 +1 @@ +export * from './notifications.interface'; \ No newline at end of file diff --git a/libs/app/notifications/utils/src/interfaces/notifications.interface.ts b/libs/app/notifications/utils/src/interfaces/notifications.interface.ts new file mode 100644 index 00000000..6c532021 --- /dev/null +++ b/libs/app/notifications/utils/src/interfaces/notifications.interface.ts @@ -0,0 +1,11 @@ +export interface INotification { + userName: string; + profilePictureUrl: string; + comment: string; + recipeId: string; +} + +export interface INotificationResponse { + general: INotification[]; + recommendations: INotification[]; +} diff --git a/libs/app/notifications/utils/src/test-setup.ts b/libs/app/notifications/utils/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/notifications/utils/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/notifications/utils/tsconfig.json b/libs/app/notifications/utils/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/notifications/utils/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/notifications/utils/tsconfig.lib.json b/libs/app/notifications/utils/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/notifications/utils/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/notifications/utils/tsconfig.spec.json b/libs/app/notifications/utils/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/notifications/utils/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/preferences/data-access/.eslintrc.json b/libs/app/preferences/data-access/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/preferences/data-access/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/preferences/data-access/README.md b/libs/app/preferences/data-access/README.md new file mode 100644 index 00000000..af4481ab --- /dev/null +++ b/libs/app/preferences/data-access/README.md @@ -0,0 +1,7 @@ +# app-preferences-data-access + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-preferences-data-access` to execute the unit tests. diff --git a/libs/app/preferences/data-access/jest.config.ts b/libs/app/preferences/data-access/jest.config.ts new file mode 100644 index 00000000..b04931d5 --- /dev/null +++ b/libs/app/preferences/data-access/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-preferences-data-access', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/preferences/data-access', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/preferences/data-access/project.json b/libs/app/preferences/data-access/project.json new file mode 100644 index 00000000..839c8c07 --- /dev/null +++ b/libs/app/preferences/data-access/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-preferences-data-access", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/preferences/data-access/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/preferences/data-access/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/preferences/data-access/**/*.ts", + "libs/app/preferences/data-access/**/*.html" + ] + } + } + } +} diff --git a/libs/app/preferences/data-access/src/index.ts b/libs/app/preferences/data-access/src/index.ts new file mode 100644 index 00000000..9808e2b9 --- /dev/null +++ b/libs/app/preferences/data-access/src/index.ts @@ -0,0 +1,2 @@ +export * from './preferences.module'; +export * from './preferences.state'; diff --git a/libs/app/preferences/data-access/src/preferences.api.ts b/libs/app/preferences/data-access/src/preferences.api.ts new file mode 100644 index 00000000..1c4f9e8a --- /dev/null +++ b/libs/app/preferences/data-access/src/preferences.api.ts @@ -0,0 +1,47 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { environment } from '@fridge-to-plate/app/environments/utils'; +import { ShowError } from '@fridge-to-plate/app/error/utils'; +import { IPreferences } from '@fridge-to-plate/app/preferences/utils'; +import { Store } from '@ngxs/store'; + + +@Injectable({ + providedIn: 'root', +}) +export class PreferencesAPI { + + constructor(private http: HttpClient, private store: Store) {} + + private baseUrl = environment.API_URL + "/preferences"; + + updatePreference(preferences: IPreferences) { + + const username = preferences.username; + + const url = `${this.baseUrl}/${username}` ; + + this.http.put(url, preferences).subscribe({ + error: error => { + this.store.dispatch(new ShowError(error)); + } + }) + } + + savePreferences(preferences: IPreferences) { + + const url = `${this.baseUrl}/create`; + + this.http.post(url, preferences).subscribe({ + error: error => { + this.store.dispatch(new ShowError(error)); + } + }); + } + + getPreferences(username: string) { + const url = `${this.baseUrl}/${username}`; + + return this.http.get(url); + } +} \ No newline at end of file diff --git a/libs/app/preferences/data-access/src/preferences.module.ts b/libs/app/preferences/data-access/src/preferences.module.ts new file mode 100644 index 00000000..60ef3d99 --- /dev/null +++ b/libs/app/preferences/data-access/src/preferences.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NgxsModule } from '@ngxs/store'; +import { PreferencesState } from './preferences.state'; +import { PreferencesAPI } from './preferences.api'; + +@NgModule({ + imports: [ + CommonModule, + NgxsModule.forFeature([PreferencesState]) + ], + providers: [PreferencesAPI] +}) +export class PreferencesDataAccessModule {} diff --git a/libs/app/preferences/data-access/src/preferences.state.ts b/libs/app/preferences/data-access/src/preferences.state.ts new file mode 100644 index 00000000..c38e74a7 --- /dev/null +++ b/libs/app/preferences/data-access/src/preferences.state.ts @@ -0,0 +1,77 @@ +import { Injectable } from "@angular/core"; +import { IPreferences, UpdatePreferences, ResetPreferences, RetrievePreferences, CreateNewPreferences } from "@fridge-to-plate/app/preferences/utils"; +import { Action, Selector, State, StateContext, Store } from "@ngxs/store"; +import { PreferencesAPI } from "./preferences.api"; +import { ShowError } from "@fridge-to-plate/app/error/utils"; +import { environment } from "@fridge-to-plate/app/environments/utils"; + +export interface PreferencesStateModel { + preferences: IPreferences | null; +} + +@State({ + name: 'preferences', + defaults: { + preferences: environment.TYPE === "production" ? null : { + username: "jdoe", + darkMode: false, + recommendNotif: false, + viewsNotif: false, + reviewNotif: false, + } + } +}) + +@Injectable() +export class PreferencesState { + + constructor(private api: PreferencesAPI, private store: Store) {} + + + @Selector() + static getPreference(state: PreferencesStateModel) { + return state.preferences; + } + + @Selector() + static get(state: PreferencesStateModel) { + return state.preferences; + } + + @Action(UpdatePreferences) + updatePreference({ patchState } : StateContext, { preferences } : UpdatePreferences) { + patchState({ + preferences: preferences + }); + this.api.updatePreference(preferences); + } + + @Action(ResetPreferences) + resetPreferences({ setState } : StateContext) { + setState({ + preferences: null + }) + } + + @Action(CreateNewPreferences) + createNewPreferences({ setState } : StateContext, { preferences } : CreateNewPreferences) { + setState({ + preferences: preferences + }); + this.api.savePreferences(preferences); + } + + @Action(RetrievePreferences) + async retrievePreferences({ setState } : StateContext, { username } : RetrievePreferences) { + (await this.api.getPreferences(username)).subscribe({ + next: data => { + setState({ + preferences: data + }); + }, + error: error => { + this.store.dispatch(new ShowError(error)); + } + }); + } +} diff --git a/libs/app/preferences/data-access/src/test-setup.ts b/libs/app/preferences/data-access/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/preferences/data-access/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/preferences/data-access/tsconfig.json b/libs/app/preferences/data-access/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/preferences/data-access/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/preferences/data-access/tsconfig.lib.json b/libs/app/preferences/data-access/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/preferences/data-access/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/preferences/data-access/tsconfig.spec.json b/libs/app/preferences/data-access/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/preferences/data-access/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/preferences/utils/.eslintrc.json b/libs/app/preferences/utils/.eslintrc.json new file mode 100644 index 00000000..6bac7be5 --- /dev/null +++ b/libs/app/preferences/utils/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "fridgeToPlate", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "fridge-to-plate", + "style": "kebab-case" + } + ] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/app/preferences/utils/README.md b/libs/app/preferences/utils/README.md new file mode 100644 index 00000000..b2be6150 --- /dev/null +++ b/libs/app/preferences/utils/README.md @@ -0,0 +1,7 @@ +# app-preferences-utils + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test app-preferences-utils` to execute the unit tests. diff --git a/libs/app/preferences/utils/jest.config.ts b/libs/app/preferences/utils/jest.config.ts new file mode 100644 index 00000000..556133bb --- /dev/null +++ b/libs/app/preferences/utils/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'app-preferences-utils', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/app/preferences/utils', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/app/preferences/utils/project.json b/libs/app/preferences/utils/project.json new file mode 100644 index 00000000..7f966152 --- /dev/null +++ b/libs/app/preferences/utils/project.json @@ -0,0 +1,34 @@ +{ + "name": "app-preferences-utils", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/app/preferences/utils/src", + "prefix": "fridge-to-plate", + "tags": [], + "projectType": "library", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/app/preferences/utils/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/app/preferences/utils/**/*.ts", + "libs/app/preferences/utils/**/*.html" + ] + } + } + } +} diff --git a/libs/app/preferences/utils/src/index.ts b/libs/app/preferences/utils/src/index.ts new file mode 100644 index 00000000..fbe6b88b --- /dev/null +++ b/libs/app/preferences/utils/src/index.ts @@ -0,0 +1,2 @@ +export * from './interfaces'; +export * from './preferences.actions'; \ No newline at end of file diff --git a/libs/app/preferences/utils/src/interfaces/index.ts b/libs/app/preferences/utils/src/interfaces/index.ts new file mode 100644 index 00000000..d0fb5eaa --- /dev/null +++ b/libs/app/preferences/utils/src/interfaces/index.ts @@ -0,0 +1 @@ +export * from './preferences.interface'; \ No newline at end of file diff --git a/libs/app/preferences/utils/src/interfaces/preferences.interface.ts b/libs/app/preferences/utils/src/interfaces/preferences.interface.ts new file mode 100644 index 00000000..96f2fc5a --- /dev/null +++ b/libs/app/preferences/utils/src/interfaces/preferences.interface.ts @@ -0,0 +1,7 @@ +export interface IPreferences { + username: string; + darkMode: boolean; + recommendNotif: boolean; + viewsNotif: boolean; + reviewNotif: boolean; +} diff --git a/libs/app/preferences/utils/src/preferences.actions.ts b/libs/app/preferences/utils/src/preferences.actions.ts new file mode 100644 index 00000000..3b18160c --- /dev/null +++ b/libs/app/preferences/utils/src/preferences.actions.ts @@ -0,0 +1,20 @@ +import { IPreferences } from "./interfaces"; + +export class UpdatePreferences { + static readonly type = '[Preferences] UpdatePreference'; + constructor(public readonly preferences: IPreferences) {} +} + +export class CreateNewPreferences { + static readonly type = '[Preferences] CreateNewPreferences'; + constructor(public readonly preferences: IPreferences) {} +} + +export class ResetPreferences { + static readonly type = '[Preferences] ResetPreferences'; +} + +export class RetrievePreferences { + static readonly type = '[Preferences] RetrievePreferences'; + constructor(public readonly username: string) {} +} \ No newline at end of file diff --git a/libs/app/preferences/utils/src/test-setup.ts b/libs/app/preferences/utils/src/test-setup.ts new file mode 100644 index 00000000..1100b3e8 --- /dev/null +++ b/libs/app/preferences/utils/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/app/preferences/utils/tsconfig.json b/libs/app/preferences/utils/tsconfig.json new file mode 100644 index 00000000..b9e5be08 --- /dev/null +++ b/libs/app/preferences/utils/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/app/preferences/utils/tsconfig.lib.json b/libs/app/preferences/utils/tsconfig.lib.json new file mode 100644 index 00000000..91273870 --- /dev/null +++ b/libs/app/preferences/utils/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/app/preferences/utils/tsconfig.spec.json b/libs/app/preferences/utils/tsconfig.spec.json new file mode 100644 index 00000000..6e5925e5 --- /dev/null +++ b/libs/app/preferences/utils/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/app/profile/data-access/src/index.ts b/libs/app/profile/data-access/src/index.ts index 3912727d..45e0bfbf 100644 --- a/libs/app/profile/data-access/src/index.ts +++ b/libs/app/profile/data-access/src/index.ts @@ -1,2 +1,3 @@ export * from './profile.module'; +export * from './profile.state'; export * from './profile.api'; \ No newline at end of file diff --git a/libs/app/profile/data-access/src/profile.api.ts b/libs/app/profile/data-access/src/profile.api.ts index b19dfc51..23de54a9 100644 --- a/libs/app/profile/data-access/src/profile.api.ts +++ b/libs/app/profile/data-access/src/profile.api.ts @@ -1,45 +1,53 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { environment } from '@fridge-to-plate/app/environments/utils'; +import { ShowError } from '@fridge-to-plate/app/error/utils'; +import { IMealPlan } from '@fridge-to-plate/app/meal-plan/utils'; import { IProfile } from '@fridge-to-plate/app/profile/utils'; -import { IRecipe } from '@fridge-to-plate/app/recipe/utils'; - -export interface IResponse { - status: number; - message: string; - data: {}; -} - -export interface ProfileRequest extends IResponse { - data: { - profile: IProfile; - }; -} - -const baseUrl = 'http://dev-fridgetoplate-api.af-south-1.elasticbeanstalk.com/'; +import { Store } from '@ngxs/store'; @Injectable({ providedIn: 'root', }) export class ProfileAPI { - constructor(private http: HttpClient) {} - private baseUrl = "http://localhost:5000/profiles"; + constructor(private http: HttpClient, private store: Store) {} + + private baseUrl = environment.API_URL + "/profiles"; + + updateProfile(profile: IProfile) { + + const username = profile.username; + const url = `${this.baseUrl}/${username}`; + this.http.put(url, profile).subscribe({ + error: error => { + this.store.dispatch(new ShowError(error.message)); + } + }); + } + + saveProfile(profile: IProfile) { - editProfile(profile: IProfile) { + const url = `${this.baseUrl}/create`; + this.http.post(url, profile).subscribe({ + error: error => { + this.store.dispatch(new ShowError(error)); + } + }); + } - const id = profile.profileId; + getProfile(username: string) { + const url = `${this.baseUrl}/${username}`; - const url = `${this.baseUrl}/${id}` ; + return this.http.get(url); + } - this.http.put(url, profile).subscribe({ - next: data => { - console.log(data.status); - return data.status; - }, + updateMealPlan(mealPlan : IMealPlan) { + const url = this.baseUrl + '/meal-plans/save'; + this.http.post(url, mealPlan).subscribe({ error: error => { - console.error('There was an error!', error); - return error.status; + this.store.dispatch(new ShowError(error.message)); } - }) + }); } } \ No newline at end of file diff --git a/libs/app/profile/data-access/src/profile.module.ts b/libs/app/profile/data-access/src/profile.module.ts index 4b8db82a..53ccf379 100644 --- a/libs/app/profile/data-access/src/profile.module.ts +++ b/libs/app/profile/data-access/src/profile.module.ts @@ -1,9 +1,14 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { ProfileAPI } from './profile.api'; +import { NgxsModule } from '@ngxs/store'; +import { ProfileState } from './profile.state'; @NgModule({ imports: [ CommonModule, + NgxsModule.forFeature([ProfileState]) ], + providers: [ProfileAPI] }) export class ProfileDataAccessModule {} diff --git a/libs/app/profile/data-access/src/profile.state.ts b/libs/app/profile/data-access/src/profile.state.ts new file mode 100644 index 00000000..7e07424b --- /dev/null +++ b/libs/app/profile/data-access/src/profile.state.ts @@ -0,0 +1,380 @@ +import { Injectable } from "@angular/core"; +import { + IProfile, + UpdateProfile, + CreateNewProfile, + RetrieveProfile, + SaveRecipe, + RemoveSavedRecipe, + SortSavedByDifficulty, + SortSavedByNameAsc, + SortSavedByNameDesc, + SortCreatedByDifficulty, + SortCreatedByNameAsc, + ResetProfile, + UndoRemoveSavedRecipe, + UpdateMealPlan, + RemoveFromMealPlan, + AddToMealPlan, + AddCreatedRecipe +} from "@fridge-to-plate/app/profile/utils"; +import { Action, Selector, State, StateContext, Store } from "@ngxs/store"; +import { ProfileAPI } from "./profile.api"; +import { ShowError } from "@fridge-to-plate/app/error/utils"; +import { ShowUndo } from "@fridge-to-plate/app/undo/utils"; +import { MealPlanAPI } from "@fridge-to-plate/app/meal-plan/data-access"; +import { environment } from "@fridge-to-plate/app/environments/utils"; +import { IMealPlan } from "@fridge-to-plate/app/meal-plan/utils"; + +export interface ProfileStateModel { + profile: IProfile | null; +} + +@State({ + name: 'profile', + defaults: { + profile: environment.TYPE === "production" ? null : { + displayName: "John Doe", + username: "jdoe", + email: "jdoe@gmail.com", + savedRecipes: [], + ingredients: [], + profilePic: "https://source.unsplash.com/150x150/?portrait", + createdRecipes: [ + { + recipeId: "b6df9e16-4916-4869-a7d9-eb0293142f1f", + recipeImage: "https://source.unsplash.com/800x800/?food", + name: "Delicious Pasta", + tags: ["pasta", "Italian", "dinner"], + difficulty: "Easy" + }, + { + recipeId: "b6df9e16-4916-4869-a7d9-eb0293142f1f22", + recipeImage: "https://source.unsplash.com/800x800/?food", + name: "Cheesy Meal", + tags: ["pasta", "Italian", "dinner"], + difficulty: "Easy" + } + ], + currMealPlan: null + } + } +}) + +@Injectable() +export class ProfileState { + + constructor(private profileAPI: ProfileAPI, private store: Store, private readonly mealPlanAPI: MealPlanAPI) {} + + @Selector() + static getProfile(state: ProfileStateModel) { + return state.profile; + } + + @Action(UpdateProfile) + updateProfile({ patchState } : StateContext, { profile } : UpdateProfile) { + patchState({ + profile: profile + }); + this.profileAPI.updateProfile(profile); + } + + @Action(ResetProfile) + resetProfile({ setState } : StateContext) { + setState({ + profile: null + }) + } + + @Action(CreateNewProfile) + createNewProfile({ setState } : StateContext, { profile } : CreateNewProfile) { + setState({ + profile: profile + }); + this.profileAPI.saveProfile(profile); + } + + @Action(RetrieveProfile) + async retrieveProfile({ setState } : StateContext, { username } : RetrieveProfile) { + (await this.profileAPI.getProfile(username)).subscribe({ + next: data => { + setState({ + profile: data + }); + }, + error: error => { + this.store.dispatch(new ShowError(error)); + } + }); + } + + @Action(AddCreatedRecipe) + addCreatedRecipe({ patchState, getState } : StateContext, { recipe } : AddCreatedRecipe) { + const updatedProfile = getState().profile; + + if (updatedProfile) { + updatedProfile?.createdRecipes.unshift(recipe); + patchState({ + profile: updatedProfile + }); + + this.profileAPI.updateProfile(updatedProfile); + } + } + + @Action(SaveRecipe) + saveRecipe({ patchState, getState } : StateContext, { recipe } : SaveRecipe) { + const updatedProfile = getState().profile; + + if (updatedProfile) { + for (let i = 0; i < updatedProfile.savedRecipes.length; i++) { + if (updatedProfile.savedRecipes[i].recipeId === recipe.recipeId) { + this.store.dispatch(new ShowError("Recipe Already Stored")); + return; + } + } + + updatedProfile?.savedRecipes.push(recipe); + patchState({ + profile: updatedProfile + }); + + this.profileAPI.updateProfile(updatedProfile); + } + } + + @Action(RemoveSavedRecipe) + removeSavedRecipe({ patchState, getState } : StateContext, { recipe } : RemoveSavedRecipe) { + const updatedProfile = getState().profile; + + if (updatedProfile) { + this.store.dispatch(new ShowUndo("Removed recipe from saved recipes", new UndoRemoveSavedRecipe(updatedProfile.savedRecipes))); + + updatedProfile.savedRecipes = updatedProfile.savedRecipes.filter((savedRecipe) => { + return savedRecipe.recipeId !== recipe.recipeId; + }); + patchState({ + profile: updatedProfile + }); + + this.profileAPI.updateProfile(updatedProfile); + } + } + + @Action(UndoRemoveSavedRecipe) + undoRemoveSavedRecipe({ patchState, getState } : StateContext, { savedRecipes } : UndoRemoveSavedRecipe) { + const updatedProfile = getState().profile; + + if (updatedProfile) { + updatedProfile.savedRecipes = savedRecipes; + patchState({ + profile: updatedProfile + }); + } + } + + @Action(SortSavedByDifficulty) + sortSavedByDifficulty({ patchState, getState } : StateContext) { + const updatedProfile = getState().profile; + + if (updatedProfile) { + updatedProfile.savedRecipes.sort(function(a, b) { + if (a.difficulty === b.difficulty) { + return 0; + } else if (a.difficulty === 'Hard') { + return 1; + } else if (a.difficulty === 'Medium' && b.difficulty === 'Easy') { + return 1; + } else { + return -1; + } + }); + patchState({ + profile: updatedProfile + }); + } + } + + @Action(SortSavedByNameAsc) + sortSavedByNameAsc({ patchState, getState } : StateContext) { + const updatedProfile = getState().profile; + + if (updatedProfile) { + updatedProfile.savedRecipes.sort(function(a, b) { + if (a.name < b.name){ + return -1; + } + if (a.name > b.name){ + return 1; + } + return 0; + }); + patchState({ + profile: updatedProfile + }); + } + } + + @Action(SortSavedByNameDesc) + sortSavedByNameDesc({ patchState, getState } : StateContext) { + const updatedProfile = getState().profile; + + if (updatedProfile) { + + updatedProfile.savedRecipes.sort(function(a, b) { + if (a.name < b.name){ + return 1; + } + if (a.name > b.name){ + return -1; + } + return 0; + }); + patchState({ + profile: updatedProfile + }); + } + } + + @Action(SortCreatedByDifficulty) + sortCreatedByDifficulty({ patchState, getState } : StateContext) { + const updatedProfile = getState().profile; + + if (updatedProfile) { + updatedProfile.createdRecipes.sort(function(a, b) { + if (a.difficulty === b.difficulty) { + return 0; + } else if (a.difficulty === 'Hard') { + return 1; + } else if (a.difficulty === 'Medium' && b.difficulty === 'Easy') { + return 1; + } else { + return -1; + } + }); + patchState({ + profile: updatedProfile + }); + } + } + + @Action(SortCreatedByNameAsc) + sortCreatedByNameAsc({ patchState, getState } : StateContext) { + const updatedProfile = getState().profile; + + if (updatedProfile) { + updatedProfile.createdRecipes.sort(function(a, b) { + if (a.name < b.name){ + return -1; + } + if (a.name > b.name){ + return 1; + } + return 0; + }); + patchState({ + profile: updatedProfile + }); + } + } + + @Action(SortSavedByNameDesc) + sortCreatedByNameDesc({ patchState, getState } : StateContext) { + const updatedProfile = getState().profile; + + if (updatedProfile) { + updatedProfile.createdRecipes.sort(function(a, b) { + if (a.name < b.name){ + return 1; + } + if (a.name > b.name){ + return -1; + } + return 0; + }); + patchState({ + profile: updatedProfile + }); + } + } + + @Action(UpdateMealPlan) + updateMealPlan({ patchState, getState } : StateContext, { mealPlan } : UpdateMealPlan) { + const updatedProfile = getState().profile; + + if (updatedProfile && mealPlan) { + updatedProfile.currMealPlan = mealPlan; + patchState({ + profile: updatedProfile + }); + this.profileAPI.updateProfile(updatedProfile); + this.mealPlanAPI.saveMealPlan(mealPlan); + } + } + + @Action(RemoveFromMealPlan) + removeFromMealPlan({ getState } : StateContext, { recipeId } : RemoveFromMealPlan) { + const profile = getState().profile; + if(!profile){ + this.store.dispatch(new ShowError("No profile: Not signed in")); + return; + } + const mealPlan = profile?.currMealPlan; + if (mealPlan) { + + if(mealPlan.breakfast && mealPlan.breakfast.recipeId === recipeId) { + mealPlan.breakfast = null; + } + if(mealPlan.lunch && mealPlan.lunch.recipeId === recipeId) { + mealPlan.lunch = null; + } + if(mealPlan.dinner && mealPlan.dinner.recipeId === recipeId) { + mealPlan.dinner = null; + } + if(mealPlan.snack && mealPlan.snack.recipeId === recipeId) { + mealPlan.snack = null; + } + this.store.dispatch(new UpdateMealPlan(mealPlan)) + } + } + + @Action(AddToMealPlan) + addToMealPlan({ getState } : StateContext, { recipe, mealType } : AddToMealPlan) { + const profile = getState().profile; + if(!profile){ + this.store.dispatch(new ShowError("No profile: Not signed in.")); + return; + } + const mealPlan = profile?.currMealPlan; + if (mealPlan) { + + if(mealType === "Breakfast") { + mealPlan.breakfast = recipe; + } + if(mealType === "Lunch") { + mealPlan.lunch = recipe; + } + if(mealType === "Dinner") { + mealPlan.dinner = recipe; + } + if(mealType === "Snack") { + mealPlan.snack = recipe; + } + this.store.dispatch(new UpdateMealPlan(mealPlan)); + + } else { + + const newMealPlan: IMealPlan = { + username: profile.username, + date: new Date().toISOString().slice(0, 10), + breakfast: mealType === "Breakfast" ? recipe : null, + lunch: mealType === "Lunch" ? recipe : null, + dinner: mealType === "Dinner" ? recipe : null, + snack: mealType === "Snack" ? recipe : null + } + this.store.dispatch(new UpdateMealPlan(newMealPlan)); + + } + } + +} diff --git a/libs/app/profile/feature/src/index.ts b/libs/app/profile/feature/src/index.ts index 3dd9438c..a9512094 100644 --- a/libs/app/profile/feature/src/index.ts +++ b/libs/app/profile/feature/src/index.ts @@ -1 +1,2 @@ export * from './profile.module'; +export * from './profile.page'; diff --git a/libs/app/profile/feature/src/profile.module.ts b/libs/app/profile/feature/src/profile.module.ts index 88a64567..c0e4d37e 100644 --- a/libs/app/profile/feature/src/profile.module.ts +++ b/libs/app/profile/feature/src/profile.module.ts @@ -4,9 +4,12 @@ import { CommonModule } from '@angular/common'; import { ProfilePage } from './profile.page'; import { ProfileRouting } from './profile.routing'; import { RecipeUIModule } from '@fridge-to-plate/app/recipe/ui'; -import { IngredientUIModule } from '@fridge-to-plate/app/ingredient/ui'; import { ProfileUiModule } from '@fridge-to-plate/app/profile/ui'; -import { NavigationBarModule } from '@fridge-to-plate/app/navigation/feature' +import { ProfileDataAccessModule } from '@fridge-to-plate/app/profile/data-access'; +// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries +import { ClickedOutsideDirective } from 'libs/app/core/src/directives/clicked-outside.directive'; +import { NotificationsFeatureModule } from '@fridge-to-plate/app/notifications/feature'; +import { NzListModule } from 'ng-zorro-antd/list'; @NgModule({ imports: [ @@ -14,10 +17,11 @@ import { NavigationBarModule } from '@fridge-to-plate/app/navigation/feature' ProfileRouting, IonicModule, RecipeUIModule, - IngredientUIModule, ProfileUiModule, - NavigationBarModule + ProfileDataAccessModule, + NotificationsFeatureModule, + NzListModule, ], - declarations: [ProfilePage], + declarations: [ProfilePage, ClickedOutsideDirective], }) export class ProfileModule {} diff --git a/libs/app/profile/feature/src/profile.page.html b/libs/app/profile/feature/src/profile.page.html index a9a45a0c..5f3ec01b 100644 --- a/libs/app/profile/feature/src/profile.page.html +++ b/libs/app/profile/feature/src/profile.page.html @@ -1,25 +1,26 @@ -
- -
+
+
Profile Pic - + class="self-center w-40 border border-black rounded-full mx-auto mt-14 aspect-square" /> +
-
-

{{ profile.name }}

+
+

{{ profile.displayName }}

{{ profile.username }}

+ +
- + -
- - -
- + + -
-
- + +
+
+
+

Breakfast

+ + + + +
+
+

Lunch

+ + + + +
+
+

Dinner

+ + + + +
+
+

Snack

+ + + + +
+
+
+ +
+
+ +
+ +
+
+
+
+ diff --git a/libs/app/profile/feature/src/profile.page.spec.ts b/libs/app/profile/feature/src/profile.page.spec.ts index 05d8f18b..341a43b8 100644 --- a/libs/app/profile/feature/src/profile.page.spec.ts +++ b/libs/app/profile/feature/src/profile.page.spec.ts @@ -1,181 +1,184 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { TestBed } from "@angular/core/testing"; import { ProfilePage } from "./profile.page"; import { IonicModule } from "@ionic/angular"; import { HttpClientModule } from "@angular/common/http"; -import { ProfileAPI } from "../../data-access/src/profile.api"; import { NavigationBarModule } from "@fridge-to-plate/app/navigation/feature"; +import { IProfile, SortCreatedByDifficulty, SortCreatedByNameAsc, SortCreatedByNameDesc, SortSavedByDifficulty, SortSavedByNameAsc, SortSavedByNameDesc } from "@fridge-to-plate/app/profile/utils"; +import { NgxsModule, State, Store } from "@ngxs/store"; +import { take } from "rxjs"; +import { Injectable } from "@angular/core"; +import { ProfileUiModule } from "@fridge-to-plate/app/profile/ui"; +// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries +import { RecipeCardComponent } from "libs/app/recipe/ui/src/recipe-card/recipe-card.component"; describe("ProfilePage", () => { - const mockProfileAPI = { - editProfile: jest.fn() - } - let testProfile = { - name: "John Doe", + const testProfile: IProfile = { + displayName: "John Doe", username: "jdoe", email: "jdoe@gmail.com", - saved_recipes: [ - { - id: "1", - name: "Shrimp Pasta", - difficulty: "Medium", - tags: ["Seafood", "Pasta"] - }, - { - id: "2", - name: "Pizza", - difficulty: "Easy", - tags: ["Italian", "Pizza"] - }, - { - id: "3", - name: "Mushroom Pie", - difficulty: "Medium", - tags: ["Quick"] - }, - { - id: "4", - name: "Beef Stew", - difficulty: "Easy", - tags: ["Winter", "Hearty"] - }, - { - id: "5", - name: "Beef Stew", - difficulty: "Easy", - tags: ["Winter", "Hearty"] - }, - { - id: "6", - name: "Beef Stew", - difficulty: "Easy", - tags: ["Winter", "Hearty"] - }, - ], - ingredients: [ - { - name: "Tomato", - amount: "3" - }, - { - name: "Cucumber", - amount: "1" - }, - { - name: "Beef", - amount: "200g" - }, - { - name: "Chicken Stock", - amount: "500ml" - }, - ], + savedRecipes: [], + ingredients: [], + profilePic: "image-url", + createdRecipes: [], + currMealPlan: null, }; + @State({ + name: 'profile', + defaults: { + profile: testProfile + } + }) + @Injectable() + class MockProfileState {} + + let page: any; + let compiled: any; + let store: Store; + let dispatchSpy: jest.SpyInstance; + beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [IonicModule, HttpClientModule, NavigationBarModule], - declarations: [ProfilePage], - providers: [{ provide: ProfileAPI, useValue: mockProfileAPI }] + imports: [IonicModule, HttpClientModule, NavigationBarModule, NgxsModule.forRoot([MockProfileState]), ProfileUiModule], + declarations: [ProfilePage, RecipeCardComponent], }).compileComponents(); - }); - it("should render users name", () => { const fixture = TestBed.createComponent(ProfilePage); fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - const page = fixture.componentInstance; - page.profile = testProfile; - expect(compiled.querySelector("h2")?.textContent).toContain(page.profile.name); + compiled = fixture.nativeElement as HTMLElement; + page = fixture.componentInstance; }); - it("should render users email", () => { - const fixture = TestBed.createComponent(ProfilePage); - fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - const page = fixture.componentInstance; - page.profile = testProfile; - expect(compiled.querySelector("p")?.textContent).toContain(page.profile.username); + it("should render users name", () => { + page.profile$.pipe(take(1)).subscribe((profile: IProfile) => { + expect(compiled.querySelector("h2")?.textContent).toContain(profile.displayName); + }) + }); + + it("should render users username", () => { + page.profile$.pipe(take(1)).subscribe((profile: IProfile) => { + expect(compiled.querySelector("h2")?.textContent).toContain(profile.username); + }) }); it("should start on saved subpage", () => { - const fixture = TestBed.createComponent(ProfilePage); - const page = fixture.componentInstance; expect(page.subpage).toEqual("saved"); }); it("should change subpage to saved", () => { - const fixture = TestBed.createComponent(ProfilePage); - const page = fixture.componentInstance; page.displaySubpage("saved"); expect(page.subpage).toEqual("saved"); }); it("should change subpage to ingredients", () => { - const fixture = TestBed.createComponent(ProfilePage); - const page = fixture.componentInstance; page.displaySubpage("ingredients"); expect(page.subpage).toEqual("ingredients"); }); - it("should remove correct ingredient from ingredients", () => { - const fixture = TestBed.createComponent(ProfilePage); - const page = fixture.componentInstance; - - const updatedIngredients = [ - { - name: "Tomato", - amount: "3" - }, - { - name: "Cucumber", - amount: "1" - }, - { - name: "Chicken Stock", - amount: "500ml" - }, - ]; + it("should change edit display to block", () => { + page.openEditProfile(); - page.profile = testProfile; + expect(page.displayEditProfile).toEqual("block"); + }); - page.removeIngredient(testProfile.ingredients[2]); + it("should change edit display to none", () => { + page.openEditProfile(); + page.closeEditProfile(); - expect(page.profile.ingredients).toEqual(updatedIngredients); + expect(page.displayEditProfile).toEqual("none"); }); - it("should change display to block", () => { - const fixture = TestBed.createComponent(ProfilePage); - const page = fixture.componentInstance; - - page.profile = testProfile; - page.openEditProfile(); + it("should change setting display to block", () => { + page.openSettings(); - expect(page.displayEditProfile).toEqual("block"); + expect(page.displaySettings).toEqual("block"); }); - it("should change display to none", () => { - const fixture = TestBed.createComponent(ProfilePage); - const page = fixture.componentInstance; + it("should change setting display to none", () => { + page.openSettings(); + page.closeSettings(); - page.profile = testProfile; - page.openEditProfile(); - page.closeEditProfile(); + expect(page.displaySettings).toEqual("none"); + }); - expect(page.displayEditProfile).toEqual("none"); + it("should change sort display to block", () => { + page.openSort(); + + expect(page.displaySort).toEqual("block"); }); - it("should remove save profile", () => { - const fixture = TestBed.createComponent(ProfilePage); - const page = fixture.componentInstance; - - mockProfileAPI.editProfile.mockReturnValue(true); + it("should change sort display to none", () => { + page.openSort(); + page.closeSort(); - page.profile = testProfile; + expect(page.displaySort).toEqual("none"); + }); + + it("should save profile", () => { page.openEditProfile(); page.editableProfile.name = "JD"; page.saveProfile(); - expect(page.profile).toEqual(page.editableProfile); + page.profile$.pipe(take(1)).subscribe((profile: IProfile) => { + expect(profile).toEqual(page.editableProfile); + }) + }); + + it("should dispatch sort saved by difficulty", () => { + store = TestBed.inject(Store); + dispatchSpy = jest.spyOn(store, 'dispatch'); + + page.sortSavedBy('difficulty'); + expect(dispatchSpy).toBeCalledWith(new SortSavedByDifficulty()); + }); + + it("should dispatch sort saved by name ascending", () => { + store = TestBed.inject(Store); + dispatchSpy = jest.spyOn(store, 'dispatch'); + + page.sortSavedBy('nameAsc'); + expect(dispatchSpy).toBeCalledWith(new SortSavedByNameAsc()); + }); + + it("should dispatch sort saved by name descending", () => { + store = TestBed.inject(Store); + dispatchSpy = jest.spyOn(store, 'dispatch'); + + page.sortSavedBy('nameDesc'); + expect(dispatchSpy).toBeCalledWith(new SortSavedByNameDesc()); }); + + it("should dispatch sort created by difficulty", () => { + store = TestBed.inject(Store); + dispatchSpy = jest.spyOn(store, 'dispatch'); + + page.sortCreatedBy('difficulty'); + expect(dispatchSpy).toBeCalledWith(new SortCreatedByDifficulty()); + }); + + it("should dispatch sort created by name ascending", () => { + store = TestBed.inject(Store); + dispatchSpy = jest.spyOn(store, 'dispatch'); + + page.sortCreatedBy('nameAsc'); + expect(dispatchSpy).toBeCalledWith(new SortCreatedByNameAsc()); + }); + + it("should dispatch sort created by name descending", () => { + store = TestBed.inject(Store); + dispatchSpy = jest.spyOn(store, 'dispatch'); + + page.sortCreatedBy('nameDesc'); + expect(dispatchSpy).toBeCalledWith(new SortCreatedByNameDesc()); + }); + + it("should open notifications page when notifications button is clicked", () => { + const openNotificationsSpy = jest.spyOn(page, 'openNotifications'); + const notificationsButton = compiled.querySelector("#notifications-button"); + notificationsButton.click(); + expect(openNotificationsSpy).toHaveBeenCalled(); + }); + }); + diff --git a/libs/app/profile/feature/src/profile.page.ts b/libs/app/profile/feature/src/profile.page.ts index 88b0bf5a..7a3c9575 100644 --- a/libs/app/profile/feature/src/profile.page.ts +++ b/libs/app/profile/feature/src/profile.page.ts @@ -1,119 +1,94 @@ import { Component } from "@angular/core"; -import { ProfileAPI } from "@fridge-to-plate/app/profile/data-access"; -import { IProfile } from '@fridge-to-plate/app/profile/utils'; -import { IRecipe } from '@fridge-to-plate/app/recipe/utils'; -import { IIngredient } from '@fridge-to-plate/app/ingredient/utils'; +import { IProfile, SortCreatedByDifficulty, SortCreatedByNameAsc, SortCreatedByNameDesc, SortSavedByDifficulty, SortSavedByNameAsc, SortSavedByNameDesc, UpdateProfile } from '@fridge-to-plate/app/profile/utils'; +import { IPreferences, UpdatePreferences } from '@fridge-to-plate/app/preferences/utils'; +import { Select, Store } from '@ngxs/store'; +import { Observable, take } from "rxjs"; +import { ProfileState } from "@fridge-to-plate/app/profile/data-access"; +import { PreferencesState } from "@fridge-to-plate/app/preferences/data-access"; +import { Navigate } from "@ngxs/router-plugin"; @Component({ + // eslint-disable-next-line @angular-eslint/component-selector selector: "profile-page", templateUrl: "./profile.page.html", styleUrls: ["./profile.page.scss"], }) +// eslint-disable-next-line @angular-eslint/component-class-suffix export class ProfilePage { + @Select(ProfileState.getProfile) profile$ !: Observable; + displayEditProfile = "none"; + displaySettings = "none"; + displaySort = "none"; + subpage = "saved"; - subpage: string = "saved"; + editableProfile !: IProfile; - profile : any; + constructor(private store: Store) { + this.profile$.pipe(take(1)).subscribe(profile => this.editableProfile = Object.create(profile)); + } - editableProfile : any; + displaySubpage(subpageName : string) { + this.subpage = subpageName; + } - ingredientArray: IIngredient = { - ingredientId: "75e4269f-c3bd-4dbf-bd2c-e1ec60ac048c", - name: "garlic" + openEditProfile() { + this.profile$.pipe(take(1)).subscribe(profile => this.editableProfile = Object.create(profile)); + this.displayEditProfile = "block"; } + closeEditProfile() { + this.displayEditProfile = "none"; + } - constructor(private api: ProfileAPI) {} - - ngOnInit() { - this.profile = { - profileId: "1", - name: "John Doe", - username: "jdoe", - email: "jdoe@gmail.com", - saved_recipes: [ - { - id: "1", - name: "Shrimp Pasta", - difficulty: "Medium", - tags: ["Seafood", "Pasta"] - }, - { - id: "2", - name: "Pizza", - difficulty: "Easy", - tags: ["Italian", "Pizza"] - }, - { - id: "3", - name: "Mushroom Pie", - difficulty: "Medium", - tags: ["Quick"] - }, - { - id: "4", - name: "Beef Stew", - difficulty: "Easy", - tags: ["Winter", "Hearty"] - }, - { - id: "5", - name: "Beef Stew", - difficulty: "Easy", - tags: ["Winter", "Hearty"] - }, - { - id: "6", - name: "Beef Stew", - difficulty: "Easy", - tags: ["Winter", "Hearty"] - }, - ], - ingredients: [ - { - name: "Tomato", - amount: "3" - }, - { - name: "Cucumber", - amount: "1" - }, - { - name: "Beef", - amount: "200g" - }, - { - name: "Chicken Stock", - amount: "500ml" - }, - ], - }; - this.editableProfile = Object.create(this.profile); + openSettings() { + this.profile$.pipe(take(1)).subscribe(profile => this.editableProfile = Object.create(profile)); + this.displaySettings = "block"; + } + closeSettings() { + this.displaySettings = "none"; } - displaySubpage(subpageName : string) { - this.subpage = subpageName; + saveProfile() { + this.store.dispatch(new UpdateProfile(this.editableProfile)); } - removeIngredient(ingredient: any) { - this.profile.ingredients = this.profile.ingredients.filter((item: any) => item !== ingredient ); + openNotifications() { + this.store.dispatch(new Navigate(['/profile/notifications'])); } - openEditProfile() { - this.editableProfile = Object.create(this.profile); - this.displayEditProfile = "block"; + openSort() { + this.displaySort = "block"; } - closeEditProfile() { - this.displayEditProfile = "none"; + closeSort() { + this.displaySort = "none"; } - saveProfile() { - this.editableProfile.profileId = "9be7b531-4980-4d3b-beff-a35d08f2637e"; - this.api.editProfile(this.editableProfile); - this.profile = this.editableProfile; + sortSavedBy(type: string) { + if (type === 'difficulty') { + this.store.dispatch(new SortSavedByDifficulty()); + } else if (type === 'nameAsc') { + this.store.dispatch(new SortSavedByNameAsc()); + } else if (type === 'nameDesc') { + this.store.dispatch(new SortSavedByNameDesc()); + } + + this.closeSort(); + } + + sortCreatedBy(type: string) { + if (type === 'difficulty') { + this.store.dispatch(new SortCreatedByDifficulty()); + } else if (type === 'nameAsc') { + this.store.dispatch(new SortCreatedByNameAsc()); + } else if (type === 'nameDesc') { + this.store.dispatch(new SortCreatedByNameDesc()); + } + + this.closeSort(); } + } diff --git a/libs/app/profile/feature/src/profile.routing.ts b/libs/app/profile/feature/src/profile.routing.ts index e0ed1dcf..8739a6e0 100644 --- a/libs/app/profile/feature/src/profile.routing.ts +++ b/libs/app/profile/feature/src/profile.routing.ts @@ -7,11 +7,18 @@ const routes: Routes = [ path: '', pathMatch: 'full', component: ProfilePage, - } + }, + { + path: 'notifications', + loadChildren: () => + import('@fridge-to-plate/app/notifications/feature').then( + (m) => m.NotificationsFeatureModule + ), + }, ]; @NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule], }) -export class ProfileRouting {} \ No newline at end of file +export class ProfileRouting {} diff --git a/libs/app/profile/ui/src/edit-modal/edit-modal.component.html b/libs/app/profile/ui/src/edit-modal/edit-modal.component.html index f9b89d13..122feb1f 100644 --- a/libs/app/profile/ui/src/edit-modal/edit-modal.component.html +++ b/libs/app/profile/ui/src/edit-modal/edit-modal.component.html @@ -1,20 +1,18 @@
-
-

Edit Profile

- - - + - - + +
diff --git a/libs/app/profile/ui/src/edit-modal/edit-modal.component.scss b/libs/app/profile/ui/src/edit-modal/edit-modal.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/libs/app/profile/ui/src/edit-modal/edit-modal.component.spec.ts b/libs/app/profile/ui/src/edit-modal/edit-modal.component.spec.ts index 2482260c..b4ceea7a 100644 --- a/libs/app/profile/ui/src/edit-modal/edit-modal.component.spec.ts +++ b/libs/app/profile/ui/src/edit-modal/edit-modal.component.spec.ts @@ -1,17 +1,25 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { EditModalComponent } from './edit-modal.component'; +import { IProfile } from '@fridge-to-plate/app/profile/utils'; +import {FormsModule} from "@angular/forms"; describe('EditModalComponent', () => { let component: EditModalComponent; let fixture: ComponentFixture; - let testProfile = { - name: "John Doe", + const testProfile: IProfile = { + displayName: "John Doe", + profilePic: "image-url", username: "jdoe", email: "jdoe@gmail.com", - } + ingredients: [], + currMealPlan: null, + savedRecipes: [], + createdRecipes: [], + }; beforeEach(async () => { await TestBed.configureTestingModule({ + imports: [FormsModule], declarations: [EditModalComponent], }).compileComponents(); @@ -24,4 +32,18 @@ describe('EditModalComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('save should call save and close func', () => { + jest.spyOn(component.saveFunc, 'emit'); + jest.spyOn(component.closeFunc, 'emit'); + component.save() + expect(component.saveFunc.emit).toBeCalled(); + expect(component.closeFunc.emit).toBeCalled(); + }); + + it('save should call close func', () => { + jest.spyOn(component.closeFunc, 'emit'); + component.close() + expect(component.closeFunc.emit).toBeCalled(); + }); }); diff --git a/libs/app/profile/ui/src/edit-modal/edit-modal.component.ts b/libs/app/profile/ui/src/edit-modal/edit-modal.component.ts index a776e80a..fb12b002 100644 --- a/libs/app/profile/ui/src/edit-modal/edit-modal.component.ts +++ b/libs/app/profile/ui/src/edit-modal/edit-modal.component.ts @@ -1,14 +1,16 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { IProfile } from '@fridge-to-plate/app/profile/utils'; @Component({ + // eslint-disable-next-line @angular-eslint/component-selector selector: 'edit-modal', templateUrl: './edit-modal.component.html', - styleUrls: ['./edit-modal.component.css'], + styleUrls: ['./edit-modal.component.scss'], }) export class EditModalComponent { @Output() closeFunc: EventEmitter = new EventEmitter(); @Output() saveFunc: EventEmitter = new EventEmitter(); - @Input() editableProfile: any; + @Input() editableProfile !: IProfile; close() { this.closeFunc.emit(); diff --git a/libs/app/profile/ui/src/password-modal/password-modal.component.html b/libs/app/profile/ui/src/password-modal/password-modal.component.html new file mode 100644 index 00000000..789f5057 --- /dev/null +++ b/libs/app/profile/ui/src/password-modal/password-modal.component.html @@ -0,0 +1,23 @@ +
+
+ +
+ +
+

Change Password

+
+ + + + + + + +
+
+
+ +
+
\ No newline at end of file diff --git a/libs/app/profile/ui/src/password-modal/password-modal.component.scss b/libs/app/profile/ui/src/password-modal/password-modal.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/libs/app/profile/ui/src/password-modal/password-modal.component.spec.ts b/libs/app/profile/ui/src/password-modal/password-modal.component.spec.ts new file mode 100644 index 00000000..1fc586c3 --- /dev/null +++ b/libs/app/profile/ui/src/password-modal/password-modal.component.spec.ts @@ -0,0 +1,99 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Store } from '@ngxs/store'; +import { PasswordModalComponent } from './password-modal.component'; +import { ChangePassword } from '@fridge-to-plate/app/auth/utils'; +import { ShowError } from '@fridge-to-plate/app/error/utils'; +import {FormsModule} from "@angular/forms"; + +describe('PasswordModalComponent', () => { + let component: PasswordModalComponent; + let fixture: ComponentFixture; + let mockStore: Partial; + + beforeEach(() => { + mockStore = { + dispatch: jest.fn() + }; + + TestBed.configureTestingModule({ + imports: [FormsModule], + declarations: [PasswordModalComponent], + providers: [{ provide: Store, useValue: mockStore }] + }).compileComponents(); + + fixture = TestBed.createComponent(PasswordModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should close the modal when close() is called', () => { + const closeFuncEmitterSpy = jest.spyOn(component.closeFunc, 'emit'); + component.close(); + expect(closeFuncEmitterSpy).toHaveBeenCalled(); + }); + + it('should dispatch ChangePassword action and emit saveFunc and closeFunc when save() is called with matching passwords', () => { + component.oldPassword = 'oldPassword'; + component.newPassword = 'newPassword'; + component.confirmPassword = 'newPassword'; // Matching with newPassword + + const saveFuncEmitterSpy = jest.spyOn(component.saveFunc, 'emit'); + const closeFuncEmitterSpy = jest.spyOn(component.closeFunc, 'emit'); + + component.save(); + + expect(mockStore.dispatch).toHaveBeenCalledWith(new ChangePassword('oldPassword', 'newPassword')); + expect(saveFuncEmitterSpy).toHaveBeenCalled(); + expect(closeFuncEmitterSpy).toHaveBeenCalled(); + }); + + it('should dispatch ShowError action when save() is called with non-matching passwords', () => { + component.oldPassword = 'oldPassword'; + component.newPassword = 'newPassword'; + component.confirmPassword = 'differentPassword'; // Non-matching with newPassword + + const saveFuncEmitterSpy = jest.spyOn(component.saveFunc, 'emit'); + const closeFuncEmitterSpy = jest.spyOn(component.closeFunc, 'emit'); + + component.save(); + + expect(mockStore.dispatch).toHaveBeenCalledWith(new ShowError('Please enter matching passwords')); + expect(saveFuncEmitterSpy).not.toHaveBeenCalled(); + expect(closeFuncEmitterSpy).not.toHaveBeenCalled(); + }); + + it('should call close() when the close button is clicked', () => { + const closeFuncEmitterSpy = jest.spyOn(component.closeFunc, 'emit'); + const closeButton = fixture.nativeElement.querySelector('#close-button'); + closeButton.click(); + expect(closeFuncEmitterSpy).toHaveBeenCalled(); + }); + + it('should call save() when the save button is clicked with matching passwords', () => { + component.oldPassword = 'oldPassword'; + component.newPassword = 'newPassword'; + component.confirmPassword = 'newPassword'; // Matching with newPassword + + const saveFuncEmitterSpy = jest.spyOn(component.saveFunc, 'emit'); + const saveButton = fixture.nativeElement.querySelector('#save-button'); + saveButton.click(); + expect(mockStore.dispatch).toHaveBeenCalledWith(new ChangePassword('oldPassword', 'newPassword')); + expect(saveFuncEmitterSpy).toHaveBeenCalled(); + }); + + it('should call save() when the save button is clicked with non-matching passwords', () => { + component.oldPassword = 'oldPassword'; + component.newPassword = 'newPassword'; + component.confirmPassword = 'differentPassword'; // Non-matching with newPassword + + const saveFuncEmitterSpy = jest.spyOn(component.saveFunc, 'emit'); + const saveButton = fixture.nativeElement.querySelector('#save-button'); + saveButton.click(); + expect(mockStore.dispatch).toHaveBeenCalledWith(new ShowError('Please enter matching passwords')); + expect(saveFuncEmitterSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/app/profile/ui/src/password-modal/password-modal.component.ts b/libs/app/profile/ui/src/password-modal/password-modal.component.ts new file mode 100644 index 00000000..c5929c33 --- /dev/null +++ b/libs/app/profile/ui/src/password-modal/password-modal.component.ts @@ -0,0 +1,40 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { ChangePassword } from '@fridge-to-plate/app/auth/utils'; +import { ShowError } from '@fridge-to-plate/app/error/utils'; +import { Store } from '@ngxs/store'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'password-modal', + templateUrl: './password-modal.component.html', + styleUrls: ['./password-modal.component.scss'], +}) + +export class PasswordModalComponent { + @Output() closeFunc: EventEmitter = new EventEmitter(); + @Output() saveFunc: EventEmitter = new EventEmitter(); + + constructor(private store: Store) { + } + + oldPassword = ""; + newPassword = ""; + confirmPassword = ""; + + close() { + this.closeFunc.emit(); + } + + save() { + + if(this.newPassword != this.confirmPassword) + this.store.dispatch( new ShowError("Please enter matching passwords")); + else{ + this.store.dispatch(new ChangePassword(this.oldPassword, this.newPassword)); + this.saveFunc.emit(); + this.closeFunc.emit(); + } + + + } +} diff --git a/libs/app/profile/ui/src/profile.module.ts b/libs/app/profile/ui/src/profile.module.ts index 681d2eb1..9311f4e1 100644 --- a/libs/app/profile/ui/src/profile.module.ts +++ b/libs/app/profile/ui/src/profile.module.ts @@ -2,13 +2,19 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { EditModalComponent } from './edit-modal/edit-modal.component'; import { FormsModule } from '@angular/forms'; +import { SettingsModalComponent } from './settings-modal/settings-modal.component'; +import { NgxsModule } from '@ngxs/store'; +import { PreferencesState } from '@fridge-to-plate/app/preferences/data-access'; +import { PasswordModalComponent } from './password-modal/password-modal.component'; + @NgModule({ imports: [ CommonModule, FormsModule, + NgxsModule.forRoot([PreferencesState]), ], - declarations: [EditModalComponent], - exports: [EditModalComponent] + declarations: [EditModalComponent, SettingsModalComponent, PasswordModalComponent], + exports: [EditModalComponent, SettingsModalComponent, PasswordModalComponent] }) export class ProfileUiModule {} diff --git a/libs/app/profile/ui/src/settings-modal/settings-modal.component.html b/libs/app/profile/ui/src/settings-modal/settings-modal.component.html new file mode 100644 index 00000000..4c23a2c0 --- /dev/null +++ b/libs/app/profile/ui/src/settings-modal/settings-modal.component.html @@ -0,0 +1,41 @@ +
+
+ +
+ +
+

Settings

+
+
+
+
+
+
+ +
+
+
+ +
+
+ diff --git a/libs/app/profile/ui/src/settings-modal/settings-modal.component.scss b/libs/app/profile/ui/src/settings-modal/settings-modal.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/libs/app/profile/ui/src/settings-modal/settings-modal.component.spec.ts b/libs/app/profile/ui/src/settings-modal/settings-modal.component.spec.ts new file mode 100644 index 00000000..3feefe91 --- /dev/null +++ b/libs/app/profile/ui/src/settings-modal/settings-modal.component.spec.ts @@ -0,0 +1,90 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SettingsModalComponent } from './settings-modal.component'; +import { IPreferences } from '@fridge-to-plate/app/preferences/utils'; +import { NgxsModule, State, Store } from '@ngxs/store'; +import { Injectable } from '@angular/core'; +import { take } from 'rxjs'; +import { Logout } from '@fridge-to-plate/app/auth/utils'; + +describe('EditModalComponent', () => { + let component: SettingsModalComponent; + let fixture: ComponentFixture; + let store: Store; + let dispatchSpy: jest.SpyInstance; + + const testPreferences: IPreferences = { + username: "testuser", + darkMode: false, + recommendNotif: false, + reviewNotif: false, + viewsNotif: false, + }; + + @State({ + name: 'preferences', + defaults: { + profile: testPreferences + } + }) + @Injectable() + class MockProfileState {} + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NgxsModule.forRoot([MockProfileState])], + declarations: [SettingsModalComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SettingsModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('save should call save func', () => { + + const updateTestPreferences: IPreferences = { + username: "testuser", + darkMode: false, + recommendNotif: true, + reviewNotif: false, + viewsNotif: false, + }; + + component.editablePreferences = updateTestPreferences; + component.save(); + + component.preferences$.pipe(take(1)).subscribe((preferences: IPreferences) => { + expect(preferences).toEqual(component.editablePreferences); + }) + }); + + it('save should call close func', () => { + jest.spyOn(component.closeFunc, 'emit'); + component.close() + expect(component.closeFunc.emit).toBeCalled(); + }); + + it('logout should call logout action', () => { + store = TestBed.inject(Store); + dispatchSpy = jest.spyOn(store, 'dispatch'); + component.logout(); + expect(dispatchSpy).toBeCalledWith(new Logout()); + }); + + it('should set displayChangePassword to "block" when calling openPassword', () => { + component.openPassword(); + + expect(component.displayChangePassword).toBe("block"); + }); + + it('should set displayChangePassword to "none" when calling closeChangePassword', () => { + component.closeChangePassword(); + + expect(component.displayChangePassword).toBe("none"); + }); + +}); diff --git a/libs/app/profile/ui/src/settings-modal/settings-modal.component.ts b/libs/app/profile/ui/src/settings-modal/settings-modal.component.ts new file mode 100644 index 00000000..30dc8c1d --- /dev/null +++ b/libs/app/profile/ui/src/settings-modal/settings-modal.component.ts @@ -0,0 +1,54 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Logout } from '@fridge-to-plate/app/auth/utils'; +import { PreferencesState } from '@fridge-to-plate/app/preferences/data-access'; +import { IPreferences, UpdatePreferences } from '@fridge-to-plate/app/preferences/utils'; +import { Select, Store } from '@ngxs/store'; +import { Observable, take } from 'rxjs'; +import { ProfileState } from "@fridge-to-plate/app/profile/data-access"; +import { IProfile } from '@fridge-to-plate/app/profile/utils'; + + + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'settings-modal', + templateUrl: './settings-modal.component.html', + styleUrls: ['./settings-modal.component.scss'], +}) +export class SettingsModalComponent { + @Output() closeFunc: EventEmitter = new EventEmitter(); + + @Select(ProfileState.getProfile) profile$ !: Observable; + + @Select(PreferencesState.getPreference) preferences$ !: Observable; + + editablePreferences !: IPreferences; + + constructor(private store: Store) { + this.preferences$.pipe(take(1)).subscribe(preferences => this.editablePreferences = Object.create(preferences)); + } + + displayChangePassword = "none"; + + close() { + this.closeFunc.emit(); + } + + save() { + this.store.dispatch(new UpdatePreferences(this.editablePreferences)); + } + + logout() { + this.store.dispatch(new Logout()); + this.close(); + } + + openPassword(){ + this.displayChangePassword = "block"; + } + + closeChangePassword() { + this.displayChangePassword = "none"; + } + +} diff --git a/libs/app/profile/utils/src/index.ts b/libs/app/profile/utils/src/index.ts index e9489de7..b55522de 100644 --- a/libs/app/profile/utils/src/index.ts +++ b/libs/app/profile/utils/src/index.ts @@ -1 +1,2 @@ -export * from './interfaces'; \ No newline at end of file +export * from './interfaces'; +export * from './profile.actions'; \ No newline at end of file diff --git a/libs/app/profile/utils/src/interfaces/profile.interface.ts b/libs/app/profile/utils/src/interfaces/profile.interface.ts index 0d1b898a..2d03eb37 100644 --- a/libs/app/profile/utils/src/interfaces/profile.interface.ts +++ b/libs/app/profile/utils/src/interfaces/profile.interface.ts @@ -1,12 +1,14 @@ import { IIngredient } from '@fridge-to-plate/app/ingredient/utils'; -import { IRecipe } from '@fridge-to-plate/app/recipe/utils'; +import { IRecipeDesc } from '@fridge-to-plate/app/recipe/utils'; +import { IMealPlan } from '@fridge-to-plate/app/meal-plan/utils'; export interface IProfile { - profileId: string; username: string; + email: string; + displayName: string; profilePic: string; ingredients: IIngredient[]; - // preferences: IPreference[]; - saved_recipes: IRecipe[]; - created_recipes: IRecipe[]; + currMealPlan: IMealPlan | null; + savedRecipes: IRecipeDesc[]; + createdRecipes: IRecipeDesc[]; } \ No newline at end of file diff --git a/libs/app/profile/utils/src/profile.actions.ts b/libs/app/profile/utils/src/profile.actions.ts new file mode 100644 index 00000000..1ff5f4dc --- /dev/null +++ b/libs/app/profile/utils/src/profile.actions.ts @@ -0,0 +1,90 @@ +import { IRecipeDesc } from '@fridge-to-plate/app/recipe/utils'; +import { IProfile } from './interfaces'; +import { IMealPlan } from '@fridge-to-plate/app/meal-plan/utils'; +import { IIngredient } from '@fridge-to-plate/app/ingredient/utils'; + +export class UpdateProfile { + static readonly type = '[Profile] UpdateProfile'; + constructor(public readonly profile: IProfile) {} +} + +export class ResetProfile { + static readonly type = '[Profile] ResetProfile'; +} + +export class CreateNewProfile { + static readonly type = '[Profile] CreateNewProfile'; + constructor(public readonly profile: IProfile) {} +} + +export class RetrieveProfile { + static readonly type = '[Profile] RetrieveProfile'; + constructor(public readonly username: string) {} +} + +export class AddCreatedRecipe { + static readonly type = '[Profile] AddCreatedRecipe'; + constructor(public readonly recipe: IRecipeDesc) {} +} + +export class SaveRecipe { + static readonly type = '[Profile] SaveRecipe'; + constructor(public readonly recipe: IRecipeDesc) {} +} + +export class RemoveSavedRecipe { + static readonly type = '[Profile] RemoveSavedRecipe'; + constructor(public readonly recipe: IRecipeDesc) {} +} + +export class UndoRemoveSavedRecipe { + static readonly type = '[Profile] UndoRemoveSavedRecipe'; + constructor(public readonly savedRecipes: IRecipeDesc[]) {} +} + +export class SortSavedByDifficulty { + static readonly type = '[Profile] SortSavedByDifficulty'; +} + +export class SortSavedByNameAsc { + static readonly type = '[Profile] SortSavedByNameAsc'; +} + +export class SortSavedByNameDesc { + static readonly type = '[Profile] SortSavedByNameDesc'; +} + +export class SortCreatedByDifficulty { + static readonly type = '[Profile] SortCreatedByDifficulty'; +} + +export class SortCreatedByNameAsc { + static readonly type = '[Profile] SortCreatedByNameAsc'; +} + +export class SortCreatedByNameDesc { + static readonly type = '[Profile] SortCreatedByNameDesc'; +} + +export class UpdateMealPlan { + static readonly type = '[Profile] Update the Meal Plan'; + constructor(public readonly mealPlan: IMealPlan) {} +} + +export class RemoveFromMealPlan { + static readonly type = '[Profile] Remove from Meal Plan'; + constructor(public readonly recipeId: string) {} +} + +export class AddToMealPlan { + static readonly type = '[Profile] Add to Meal Plan'; + constructor( + public readonly recipe: IRecipeDesc, + public readonly mealType: string + ) {} +} + +export class UpdateUserIngredients { + static readonly type = '[Profile] Update User Ingredients'; + constructor(public readonly updatedIngredientsList: IIngredient[]) {} +} diff --git a/libs/app/recipe/data-access/src/index.ts b/libs/app/recipe/data-access/src/index.ts index 33b05947..114c6ed7 100644 --- a/libs/app/recipe/data-access/src/index.ts +++ b/libs/app/recipe/data-access/src/index.ts @@ -1,3 +1,4 @@ export * from './recipe.module'; // export * from './mock-data'; export * from './recipe.api' +export * from './recipe.state' diff --git a/libs/app/recipe/data-access/src/mock-data/mock-recipe-data.ts b/libs/app/recipe/data-access/src/mock-data/mock-recipe-data.ts index 58883c00..f26ffa4b 100644 --- a/libs/app/recipe/data-access/src/mock-data/mock-recipe-data.ts +++ b/libs/app/recipe/data-access/src/mock-data/mock-recipe-data.ts @@ -142,7 +142,7 @@ // ], // meta: { // prepTime: 60, -// numberOfServings: 4, +// servings: 4, // tags: ["Healthy", "Chicken"] // } // }, diff --git a/libs/app/recipe/data-access/src/recipe.api.ts b/libs/app/recipe/data-access/src/recipe.api.ts index 28893a95..7b71d311 100644 --- a/libs/app/recipe/data-access/src/recipe.api.ts +++ b/libs/app/recipe/data-access/src/recipe.api.ts @@ -2,17 +2,48 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { IRecipe } from '@fridge-to-plate/app/recipe/utils'; +import { environment } from '@fridge-to-plate/app/environments/utils'; +import { IReview } from '@fridge-to-plate/app/review/utils'; @Injectable({ - providedIn: 'root' + providedIn: 'root', }) -export class RecipeService { - private baseUrl = 'http://localhost:5000/recipes'; +export class RecipeAPI { + private baseUrl = environment.API_URL + '/recipes'; - constructor(private http: HttpClient) { } + constructor(private http: HttpClient) {} + + createNewRecipe(recipe: IRecipe): Observable { + const url = this.baseUrl + '/create'; + return this.http.post(url, recipe); + } + UpdateRecipe(recipe: IRecipe): Observable { + const url = this.baseUrl + '/' + recipe.recipeId; + return this.http.put(url, recipe); + } + + deleteRecipe(id: string): Observable { + const url = this.baseUrl + '/' + id; + return this.http.delete(url); + } getRecipeById(id: string): Observable { const url = `${this.baseUrl}/${id}`; return this.http.get(url); } + + updateRecipe(recipe: IRecipe) { + const url = `${this.baseUrl}/${recipe.recipeId}`; + return this.http.put(url, recipe); + } + + createNewReview(review: IReview): Observable { + const url = environment.API_URL + '/reviews/create'; + return this.http.post(url, review); + } + + deleteReview(recipeId: string, reviewId:string): Observable { + const url = environment.API_URL + '/reviews/' + recipeId + '/' + reviewId; + return this.http.delete(url); + } } diff --git a/libs/app/recipe/data-access/src/recipe.module.ts b/libs/app/recipe/data-access/src/recipe.module.ts index f876f12f..41b5c16c 100644 --- a/libs/app/recipe/data-access/src/recipe.module.ts +++ b/libs/app/recipe/data-access/src/recipe.module.ts @@ -1,7 +1,9 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; +import {NgxsModule} from "@ngxs/store"; +import {RecipeState} from "./recipe.state"; @NgModule({ - imports: [CommonModule], + imports: [CommonModule, NgxsModule.forFeature([RecipeState])], }) export class RecipeDataAccessModule {} diff --git a/libs/app/recipe/data-access/src/recipe.state.ts b/libs/app/recipe/data-access/src/recipe.state.ts new file mode 100644 index 00000000..2a566f4b --- /dev/null +++ b/libs/app/recipe/data-access/src/recipe.state.ts @@ -0,0 +1,170 @@ +import { IRecipe } from '@fridge-to-plate/app/recipe/utils'; +import { Action, Selector, State, StateContext, Store } from '@ngxs/store'; +import { Injectable } from '@angular/core'; +import { RecipeAPI } from './recipe.api'; +import { + CreateRecipe, + DeleteRecipe, + RetrieveRecipe, + UpdateRecipe, + AddReview, + DeleteReview, +} from '@fridge-to-plate/app/recipe/utils'; +import { ShowError } from '@fridge-to-plate/app/error/utils'; +import { catchError, take, tap } from 'rxjs'; +import { environment } from '@fridge-to-plate/app/environments/utils'; +import { Navigate } from '@ngxs/router-plugin'; +import { AddCreatedRecipe } from '@fridge-to-plate/app/profile/utils'; + +export interface RecipeStateModel { + recipe: IRecipe | null; +} + +@State({ + name: 'recipe', + defaults: { + recipe: null, + } +}) +@Injectable() +export class RecipeState { + + constructor(private api: RecipeAPI, private store: Store) {} + + @Selector() + static getRecipe(state: RecipeStateModel) { + return state.recipe; + } + + @Action(RetrieveRecipe) + async retrieveRecipe( + { setState }: StateContext, + { recipeId }: RetrieveRecipe + ) { + + this.api.getRecipeById(recipeId).subscribe( + (recipe) => { + if (recipe) { + setState({ + recipe: recipe, + }); + } else { + this.store.dispatch( + new ShowError( + 'Error: Something is wrong with the recipe: ' + recipe + ) + ); + } + }, + (error: Error) => { + console.error('Failed to retrieve recipe:', error); + this.store.dispatch(new ShowError(error.message)); + } + ); + } + + @Action(UpdateRecipe) + updateRecipe( + { patchState }: StateContext, + { recipe }: UpdateRecipe + ) { + patchState({ + recipe: recipe, + }); + + this.api.updateRecipe(recipe).subscribe( + () => { + patchState({ + recipe: recipe, + }); + }, + (error: Error) => { + console.error('Failed to update recipe:', error); + this.store.dispatch(new ShowError(error.message)); + } + ); + } + + @Action(AddReview) + async addRecipeReview( + { getState }: StateContext, + { review }: AddReview + ) { + const updatedRecipe = getState().recipe; + + if (updatedRecipe) { + (await this.api.createNewReview(review)).subscribe({ + next: data => { + updatedRecipe.reviews?.unshift(data); + + this.store.dispatch(new UpdateRecipe(updatedRecipe)); + }, + error: error => { + this.store.dispatch(new ShowError(error.message)); + } + }); + } + } + + @Action(DeleteReview) + async removeRecipeReview( + { getState }: StateContext, + { reviewId }: DeleteReview + ) { + const updatedRecipe = getState().recipe; + + if (updatedRecipe) { + updatedRecipe.reviews = updatedRecipe?.reviews?.filter( + (currentReview) => currentReview.reviewId !== reviewId + ); + + this.store.dispatch(new UpdateRecipe(updatedRecipe)); + + (await this.api.deleteReview(updatedRecipe.recipeId as string, reviewId)).subscribe({ + error: error => { + this.store.dispatch(new ShowError(error.message)); + } + }); + } + } + + @Action(DeleteRecipe) + deleteRecipe( + { patchState }: StateContext, + { recipeId }: DeleteRecipe + ) { + patchState({ + recipe: null, + }); + + this.api.deleteRecipe(recipeId).subscribe( + (response) => { + console.log(response); + }, + (error: Error) => { + console.error('Failed to delete recipe:', error); + this.store.dispatch(new ShowError(error.message)); + } + ); + } + + @Action(CreateRecipe) + createRecipe( + { patchState }: StateContext, + { recipe }: CreateRecipe + ) { + this.api.createNewRecipe(recipe).pipe( + tap( + (recipe) => { + patchState({ + "recipe": recipe + }) + + this.store.dispatch(new Navigate([`/recipe/${recipe.recipeId}`])); + this.store.dispatch (new AddCreatedRecipe(recipe)); + }, + catchError ( + () => this.store.dispatch(new ShowError('Unfortunately, the recipe was not created successfully')) + ))).subscribe(); + } +} \ No newline at end of file diff --git a/libs/app/recipe/feature/src/recipe.module.ts b/libs/app/recipe/feature/src/recipe.module.ts index 89146563..f857dfa8 100644 --- a/libs/app/recipe/feature/src/recipe.module.ts +++ b/libs/app/recipe/feature/src/recipe.module.ts @@ -5,6 +5,10 @@ import { RecipeUIModule } from '@fridge-to-plate/app/recipe/ui'; import { IonicModule } from '@ionic/angular'; import { RecipeRouting } from './recipe.routing'; import { NavigationBarModule } from '@fridge-to-plate/app/navigation/feature'; +// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries +import { ReviewModule } from '@fridge-to-plate/app/review/feature'; +// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries +import { RecipeDataAccessModule } from '@fridge-to-plate/app/recipe/data-access'; @NgModule({ imports: [ @@ -13,6 +17,8 @@ import { NavigationBarModule } from '@fridge-to-plate/app/navigation/feature'; IonicModule, RecipeRouting, NavigationBarModule, + ReviewModule, + RecipeDataAccessModule, ], declarations: [RecipePage], }) diff --git a/libs/app/recipe/feature/src/recipe.page.html b/libs/app/recipe/feature/src/recipe.page.html index 83b17db8..94bf2b59 100644 --- a/libs/app/recipe/feature/src/recipe.page.html +++ b/libs/app/recipe/feature/src/recipe.page.html @@ -1,11 +1,15 @@ -
- -
-
+ + +
+
+
-
+ >
-

Prep Time

+

Prep Time

{{recipe?.prepTime ?? '10'}}m

Ingredients

-

{{recipe?.ingredients?.length}}

+

{{recipe?.ingredients?.length ?? '4'}}

Servings

- {{recipe?.numberOfServings ?? '1'}} + {{recipe?.servings ?? '1'}}

-
+
Ingredients -
{{ingredient?.name ?? 'Unknown ingredient'}}
+
{{ingredient?.amount ?? ''}}{{ingredient?.unit ?? ''}} {{ingredient?.name ?? 'Unknown ingredient'}}
+
-
+ +
Instructions - -
{{instruction?.instructionBody ?? 'Unknown ingredient'}}
+ +
{{ instruction }}
-
+ +
+ +
+ +
+ +
+ + + +
+
+

Unfortunately, No recipe available to be displayed

+
+ +
+
+
+
+ + + + + diff --git a/libs/app/recipe/feature/src/recipe.page.spec.ts b/libs/app/recipe/feature/src/recipe.page.spec.ts index cd0f9786..2e26ad8a 100644 --- a/libs/app/recipe/feature/src/recipe.page.spec.ts +++ b/libs/app/recipe/feature/src/recipe.page.spec.ts @@ -2,39 +2,55 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RecipePage } from './recipe.page'; import { IonicModule } from '@ionic/angular'; -import { HttpClient, HttpClientModule } from '@angular/common/http'; +import { HttpClientModule } from '@angular/common/http'; import { RouterTestingModule } from '@angular/router/testing'; +// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries import { RecipeUIModule } from '@fridge-to-plate/app/recipe/ui'; import { NavigationBarModule } from '@fridge-to-plate/app/navigation/feature'; import { IRecipe } from '@fridge-to-plate/app/recipe/utils'; -import { Observable, of, throwError } from 'rxjs'; +import { of } from 'rxjs'; import { Location } from '@angular/common'; -import { RecipeService } from '@fridge-to-plate/app/recipe/data-access'; +import { RecipeAPI } from '@fridge-to-plate/app/recipe/data-access'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { NgxsModule, Store } from '@ngxs/store'; +import { ReviewModule } from '@fridge-to-plate/app/review/feature'; +import { Navigate } from '@ngxs/router-plugin'; describe('RecipeDetailPageComponent', () => { let location: Location; - let recipeService: RecipeService; let component: RecipePage; let fixture: ComponentFixture; - let testRecipe: IRecipe = { + const testRecipe: IRecipe = { recipeId: "test-id", name: "Test Recipe", - difficulty: "easy", + difficulty: "Easy", recipeImage: "url.com/image", - ingredients: [], - instructions: [], + ingredients: [ + { + name: 'Carrot', + unit: 'ml', + amount: 10, + }, + ], + description: 'Heading', + tags: ['Paleo'], + servings: 2, + prepTime: 30, + meal: 'Snack', + steps: ['Chop onions'], + creator: "Kristap P", }; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [RecipePage], - imports: [IonicModule, HttpClientModule, RouterTestingModule, RecipeUIModule, NavigationBarModule], + imports: [ReviewModule, IonicModule, HttpClientModule, RouterTestingModule, RecipeUIModule, NavigationBarModule, NgxsModule.forRoot()], providers: [HttpClientModule] }) .compileComponents(); fixture = TestBed.createComponent(RecipePage); component = fixture.componentInstance; + component.recipe = testRecipe; fixture.detectChanges(); location = TestBed.inject(Location); }); @@ -45,6 +61,7 @@ describe('RecipeDetailPageComponent', () => { it('should observe recipe details', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars jest.spyOn(component, 'setRecipe').mockImplementation((id: string) => component.recipe = testRecipe); component.setRecipe("test-id"); expect(component.recipe).toEqual(testRecipe); @@ -69,29 +86,9 @@ describe('RecipeDetailPageComponent', () => { expect(setRecipeSpy).toHaveBeenCalledWith('test-id'); }); - it('should retrieve recipe data correctly in setRecipe', () => { - const recipeService: RecipeService = TestBed.inject(RecipeService); - const getRecipeByIdSpy = jest.spyOn(recipeService, 'getRecipeById').mockReturnValue(of(testRecipe)); - - component.setRecipe('test-id'); - - expect(getRecipeByIdSpy).toHaveBeenCalledWith('test-id'); - expect(component.recipe).toEqual(testRecipe); - }); - - it('should handle error when retrieving recipe data', () => { - const recipeService: RecipeService = TestBed.inject(RecipeService); - const getRecipeByIdSpy = jest.spyOn(recipeService, 'getRecipeById').mockReturnValue(throwError('Error')); - - component.setRecipe('test-id'); - - expect(getRecipeByIdSpy).toHaveBeenCalledWith('test-id'); - expect(component.recipe).toBeUndefined(); - expect(component.errorMessage).toBe('Error retrieving recipe data.'); - }); - it('should not retrieve recipe data with empty id', () => { - const recipeService: RecipeService = TestBed.inject(RecipeService); + component.recipe = undefined; + const recipeService: RecipeAPI = TestBed.inject(RecipeAPI); const getRecipeByIdSpy = jest.spyOn(recipeService, 'getRecipeById'); component.setRecipe(''); @@ -100,4 +97,33 @@ it('should not retrieve recipe data with empty id', () => { expect(component.recipe).toBeUndefined(); }); +it('Should set forceLoading to false after the timer is done', ()=> { + jest.useFakeTimers(); + component.forceLoading = true; + component.ngOnInit(); + jest.advanceTimersByTime(1000); + expect(component.forceLoading).toBe(false); +}) + +it('Should go to the Home Page', () => { + const dispatchSpy = jest.spyOn(TestBed.inject(Store), 'dispatch'); + component.goHome(); + expect(dispatchSpy).toHaveBeenCalledWith(new Navigate(['/home'])); +}); + +it('Should set forceLoading to false after the timer is done', ()=> { + jest.useFakeTimers(); + component.forceLoading = true; + component.ngOnInit(); + jest.advanceTimersByTime(1000); + expect(component.forceLoading).toBe(false); +}) + +it('Should go to the Home Page', () => { + const dispatchSpy = jest.spyOn(TestBed.inject(Store), 'dispatch'); + component.goHome(); + expect(dispatchSpy).toHaveBeenCalledWith(new Navigate(['/home'])); +}) + + }); diff --git a/libs/app/recipe/feature/src/recipe.page.ts b/libs/app/recipe/feature/src/recipe.page.ts index 2029deec..2e6d35f0 100644 --- a/libs/app/recipe/feature/src/recipe.page.ts +++ b/libs/app/recipe/feature/src/recipe.page.ts @@ -1,32 +1,49 @@ import { Component, OnInit } from '@angular/core'; - -import { RecipeService } from '@fridge-to-plate/app/recipe/data-access'; -import { IRecipe } from '@fridge-to-plate/app/recipe/utils'; +import { IRecipe, RetrieveRecipe } from '@fridge-to-plate/app/recipe/utils'; import { ActivatedRoute } from '@angular/router'; import { Location } from '@angular/common'; - +import { Select, Store, Actions, ofActionSuccessful } from '@ngxs/store'; +import { RecipeState } from '@fridge-to-plate/app/recipe/data-access'; +import { Observable } from 'rxjs'; +import { ShowError } from '@fridge-to-plate/app/error/utils'; +import { Navigate } from '@ngxs/router-plugin'; +import { actionsExecuting, ActionsExecuting} from '@ngxs-labs/actions-executing'; @Component({ + // eslint-disable-next-line @angular-eslint/component-selector selector: 'recipe-page', templateUrl: './recipe.page.html', styleUrls: ['./recipe.page.scss'], }) +// eslint-disable-next-line @angular-eslint/component-class-suffix export class RecipePage implements OnInit { - recipe!: IRecipe; + @Select(RecipeState.getRecipe) recipe$!: Observable; + @Select(actionsExecuting([RetrieveRecipe])) busy$ !: Observable + recipe: IRecipe | undefined = undefined; errorMessage: string | undefined; + forceLoading = true; constructor( private location: Location, - private recipeService: RecipeService, - private route: ActivatedRoute + private route: ActivatedRoute, + private store: Store, + private actions$: Actions ) {} ngOnInit(): void { + this.forceLoading = true; + setTimeout(() => { + this.forceLoading = false; + }, 1000); this.route.paramMap.subscribe((params) => { const recipeId = params.get('id'); if (recipeId) { this.setRecipe(recipeId); } + else { + this.store.dispatch(new ShowError('Invalid Recipe Id')); + } }); + } goBack() { @@ -34,19 +51,20 @@ export class RecipePage implements OnInit { } setRecipe(id: string) { - if (!id || id.length == 0) { - this.errorMessage = 'Invalid recipe ID.'; + if (id || id != '' || id.length > 0) { + this.store.dispatch(new RetrieveRecipe(id)); + this.recipe$.subscribe((stateRecipe) => { + this.recipe = stateRecipe; + }); + } + else { + this.store.dispatch(new ShowError('Invalid Recipe Id')); return; } - this.recipeService.getRecipeById(id).subscribe( - (response: IRecipe) => { - this.recipe = response; - }, - error => { - this.errorMessage = 'Error retrieving recipe data.'; - } - ); } -} + goHome() { + this.store.dispatch(new Navigate(['/home'])); + } +} diff --git a/libs/app/recipe/ui/src/index.ts b/libs/app/recipe/ui/src/index.ts index 27d93e30..120191bd 100644 --- a/libs/app/recipe/ui/src/index.ts +++ b/libs/app/recipe/ui/src/index.ts @@ -1 +1 @@ -export * from './recipe.module'; +export * from './recipe.module'; \ No newline at end of file diff --git a/libs/app/recipe/ui/src/meal-plan-modal/meal-plan-modal.component.html b/libs/app/recipe/ui/src/meal-plan-modal/meal-plan-modal.component.html new file mode 100644 index 00000000..7b677aac --- /dev/null +++ b/libs/app/recipe/ui/src/meal-plan-modal/meal-plan-modal.component.html @@ -0,0 +1,21 @@ +
+
+ +
+ +
+

Choose Specific Meal

+
+ + + + + +
+
+
+ +
+
\ No newline at end of file diff --git a/libs/app/recipe/ui/src/meal-plan-modal/meal-plan-modal.component.scss b/libs/app/recipe/ui/src/meal-plan-modal/meal-plan-modal.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/libs/app/recipe/ui/src/meal-plan-modal/meal-plan-modal.component.spec.ts b/libs/app/recipe/ui/src/meal-plan-modal/meal-plan-modal.component.spec.ts new file mode 100644 index 00000000..d84eafed --- /dev/null +++ b/libs/app/recipe/ui/src/meal-plan-modal/meal-plan-modal.component.spec.ts @@ -0,0 +1,36 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MealPlanModalComponent } from './meal-plan-modal.component'; + +describe('EditModalComponent', () => { + let component: MealPlanModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [MealPlanModalComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(MealPlanModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('save should call save and close func', () => { + jest.spyOn(component.saveFunc, 'emit'); + jest.spyOn(component.closeFunc, 'emit'); + component.save("Breakfast") + expect(component.saveFunc.emit).toBeCalled(); + expect(component.saveFunc.emit).toBeCalledWith("Breakfast"); + expect(component.closeFunc.emit).toBeCalled(); + }); + + it('save should call close func', () => { + jest.spyOn(component.closeFunc, 'emit'); + component.close() + expect(component.closeFunc.emit).toBeCalled(); + }); +}); diff --git a/libs/app/recipe/ui/src/meal-plan-modal/meal-plan-modal.component.ts b/libs/app/recipe/ui/src/meal-plan-modal/meal-plan-modal.component.ts new file mode 100644 index 00000000..9474b890 --- /dev/null +++ b/libs/app/recipe/ui/src/meal-plan-modal/meal-plan-modal.component.ts @@ -0,0 +1,21 @@ +import { Component, EventEmitter, Output } from '@angular/core'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'meal-plan-modal', + templateUrl: './meal-plan-modal.component.html', + styleUrls: ['./meal-plan-modal.component.scss'], +}) +export class MealPlanModalComponent { + @Output() closeFunc: EventEmitter = new EventEmitter(); + @Output() saveFunc: EventEmitter = new EventEmitter(); + + close() { + this.closeFunc.emit(); + } + + save(meal: string) { + this.saveFunc.emit(meal); + this.closeFunc.emit(); + } +} diff --git a/libs/app/recipe/ui/src/recipe-card/recipe-card.component.html b/libs/app/recipe/ui/src/recipe-card/recipe-card.component.html index b6d2bdcd..30bedf74 100644 --- a/libs/app/recipe/ui/src/recipe-card/recipe-card.component.html +++ b/libs/app/recipe/ui/src/recipe-card/recipe-card.component.html @@ -1,14 +1,39 @@ -
- +
+
-

{{ recipe?.name }}

-

Difficulty: {{ recipe?.difficulty }}

+

{{ recipe.name }}

+

Difficulty: {{ recipe.difficulty }}

+ {{ tag }} + + +
+ + diff --git a/libs/app/recipe/ui/src/recipe-card/recipe-card.component.spec.ts b/libs/app/recipe/ui/src/recipe-card/recipe-card.component.spec.ts index 69693471..25f706ee 100644 --- a/libs/app/recipe/ui/src/recipe-card/recipe-card.component.spec.ts +++ b/libs/app/recipe/ui/src/recipe-card/recipe-card.component.spec.ts @@ -1,49 +1,83 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RecipeCardComponent } from './recipe-card.component'; import { IonicModule } from '@ionic/angular'; -import { IRecipe } from '@fridge-to-plate/app/recipe/utils'; +import { IRecipe, IRecipeDesc } from '@fridge-to-plate/app/recipe/utils'; import { HttpClientModule } from '@angular/common/http'; -import { ProfileAPI } from '@fridge-to-plate/app/profile/data-access'; +import { Router } from '@angular/router'; +import { NgxsModule, State, Store } from '@ngxs/store'; +import { Injectable } from '@angular/core'; +import { IProfile, SaveRecipe, RemoveSavedRecipe, AddToMealPlan, RemoveFromMealPlan } from '@fridge-to-plate/app/profile/utils'; +import { RouterTestingModule } from '@angular/router/testing'; +import { ShowError } from '@fridge-to-plate/app/error/utils'; +import { IMealPlan } from '@fridge-to-plate/app/meal-plan/utils'; +import { Navigate } from '@ngxs/router-plugin'; +import { LoadRecipe } from '@fridge-to-plate/app/edit-recipe/utils'; describe('RecipeCardComponent', () => { - const mockProfileAPI = { - editProfile: jest.fn(), - }; - let component: RecipeCardComponent; let fixture: ComponentFixture; - let testRecipe: IRecipe; + let store: Store; + let dispatchSpy: jest.SpyInstance; - testRecipe = { + const testRecipe: IRecipe = { recipeId: 'test-id', name: 'Pizza', recipeImage: 'image-url', - difficulty: 'easy', + difficulty: 'Easy', ingredients: [ { - ingredientId: 'test-id', name: 'Carrot', + unit: 'ml', + amount: 10, }, ], - instructions: [ - { - instructionHeading: 'Heading', - instructionBody: 'Body', - }, - ], + description: 'Heading', tags: ['Paleo'], + servings: 2, + prepTime: 30, + meal: 'Snack', + steps: ['Chop onions'], + creator: "Kristap P", }; + const testProfile: IProfile = { + displayName: "John Doe", + username: "jdoe", + email: "jdoe@gmail.com", + savedRecipes: [], + ingredients: [], + profilePic: "image-url", + createdRecipes: [], + currMealPlan: { + username: "jdoe", + date: "", + breakfast: null, + lunch: testRecipe, + dinner: null, + snack: null + }, + } + + @State({ + name: 'profile', + defaults: { + profile: testProfile + } + }) + @Injectable() + class MockProfileState {} + beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [RecipeCardComponent], - imports: [IonicModule, HttpClientModule], - providers: [{ provide: ProfileAPI, useValue: mockProfileAPI }], + imports: [IonicModule, HttpClientModule, RouterTestingModule, NgxsModule.forRoot([MockProfileState])], }).compileComponents(); fixture = TestBed.createComponent(RecipeCardComponent); component = fixture.componentInstance; component.recipe = testRecipe; + store = TestBed.inject(Store); + dispatchSpy = jest.spyOn(store, 'dispatch'); fixture.detectChanges(); }); @@ -52,21 +86,206 @@ describe('RecipeCardComponent', () => { }); it('should be saved', () => { + component.bookmarked = false; + component.bookmarked = false; component.changeSaved(); - expect(component.bookmarked).toEqual(true); + expect(dispatchSpy).toBeCalledWith(new SaveRecipe(component.recipe as IRecipeDesc)); + expect(dispatchSpy).toBeCalledWith(new SaveRecipe(component.recipe as IRecipeDesc)); }); it('should be unsaved', () => { - mockProfileAPI.editProfile.mockReturnValue(true); - - const testProfile = { - saved_recipes: [], - }; - - component.profile = testProfile; component.bookmarked = true; component.changeSaved(); + expect(dispatchSpy).toBeCalledWith(new RemoveSavedRecipe(component.recipe as IRecipeDesc)); + }); + + // Tests that the user can navigate to the edit-recipe page with the correct query params + it('test edit recipe with recipe id', () => { + component.recipe = { recipeId: '123' }; + component.edit(); + expect(store.dispatch).toBeCalledWith(new LoadRecipe(component.recipe.recipeId)); + }); + + it('test edit recipe with undefined recipe', () => { + const showErrorSpy = jest.spyOn(TestBed.inject(Store), 'dispatch'); + component.recipe = undefined; + component.edit(); + expect(showErrorSpy).toHaveBeenCalledWith(new ShowError('ERROR: No recipe available to edit.')); +}); + + it('Should dispatch Load recipe Action', ()=>{ + const loadRecipeSpy = jest.spyOn(TestBed.inject(Store), 'dispatch'); + component.recipe = { recipeId : 'Valid_recipe_id'} as IRecipe; + component.edit(); + expect(loadRecipeSpy).toHaveBeenCalledWith(new LoadRecipe('Valid_recipe_id')); + }) + + // Tests that toggleDropdown method toggles the value of 'showMenu' from false to true + it('test toggle dropdown toggles show menu from false to true', () => { + component.showMenu = false; + component.toggleMealPlan(); + expect(component.showMenu).toBe(true); +}); + + // Tests that a recipe can be added to the meal plan successfully + it('test add to meal plan successfully', () => { + component.mealType = 'Breakfast'; + component.addToMealPlan("Breakfast"); + expect(component.added).toBe(true); + expect(store.dispatch).toBeCalledWith(new AddToMealPlan(testRecipe, "Breakfast")); + }); + + it('should set added to true if recipe is in meal plan', () => { + component.ngOnInit(); + expect(component.added).toBe(true); + }); + + it('should dispatch ShowError action if recipe is not available to add to meal plan', () => { + component.recipe = null; + component.addToMealPlan("Breakfast"); + expect(store.dispatch).toHaveBeenCalledWith(new ShowError('ERROR: No recipe available to add to meal plan.')); + }); + + it('should dispatch ShowError action if recipe is not available to remove from meal plan', () => { + component.recipe = null; + component.removeFromMealPlan(); + expect(store.dispatch).toHaveBeenCalledWith(new ShowError('ERROR: No recipe available to remove from meal plan.')); + }); + + it('should return true if mealPlan has breakfast', () => { + const testMealPlan: IMealPlan = { + username: "jdoe", + date: "", + breakfast: testRecipe, + lunch: null, + dinner: null, + snack: null + } + + expect(component.checkMealPlan(testMealPlan)).toBe(true); + }); + + it('should return true if mealPlan has lunch', () => { + const testMealPlan: IMealPlan = { + username: "jdoe", + date: "", + breakfast: null, + lunch: testRecipe, + dinner: null, + snack: null + } + + expect(component.checkMealPlan(testMealPlan)).toBe(true); + }); + + it('should return true if mealPlan has dinner', () => { + const testMealPlan: IMealPlan = { + username: "jdoe", + date: "", + breakfast: null, + lunch: null, + dinner: testRecipe, + snack: null + } + + expect(component.checkMealPlan(testMealPlan)).toBe(true); + }); + + it('should return true if mealPlan has snack', () => { + const testMealPlan: IMealPlan = { + username: "jdoe", + date: "", + breakfast: null, + lunch: null, + dinner: null, + snack: testRecipe + } + + expect(component.checkMealPlan(testMealPlan)).toBe(true); + }); - expect(component.bookmarked).toEqual(false); + it('should return false if mealPlan no meals', () => { + const testMealPlan: IMealPlan = { + username: "jdoe", + date: "", + breakfast: null, + lunch: null, + dinner: null, + snack: null + } + + expect(component.checkMealPlan(testMealPlan)).toBe(false); + }); + + it('should return false if mealPlan is null', () => { + expect(component.checkMealPlan(null)).toBe(false); + }); + + it('should dispatch RemoveFromMealPlan when removeFromMealPlan is called', () => { + component.removeFromMealPlan(); + expect(store.dispatch).toHaveBeenCalledWith(new RemoveFromMealPlan(testRecipe.recipeId as string)); + expect(component.added).toBe(false); + }); + + it('should navigate to recipe page', () => { + component.navigateToRecipe(); + expect(store.dispatch).toBeCalledWith(new Navigate([`/recipe/${testRecipe.recipeId}`])); }); }); + +describe('RecipeCardComponent', () => { + + let component: RecipeCardComponent; + let fixture: ComponentFixture; + let store: Store; + + const testRecipe: IRecipe = { + recipeId: 'test-id', + name: 'Pizza', + recipeImage: 'image-url', + difficulty: 'Easy', + ingredients: [ + { + name: 'Carrot', + unit: 'ml', + amount: 10, + }, + ], + description: 'Heading', + tags: ['Paleo'], + servings: 2, + prepTime: 30, + meal: 'Snack', + steps: ['Chop onions'], + creator: "Kristap P", + }; + + @State({ + name: 'profile', + defaults: { + profile: null + } + }) + @Injectable() + class MockProfileState {} + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [RecipeCardComponent], + imports: [IonicModule, HttpClientModule, RouterTestingModule, NgxsModule.forRoot([MockProfileState])], + }).compileComponents(); + + fixture = TestBed.createComponent(RecipeCardComponent); + component = fixture.componentInstance; + component.recipe = testRecipe; + store = TestBed.inject(Store); + fixture.detectChanges(); + }); + + it('should be default if profile becomes null', () => { + component.ngOnInit(); + expect(component.bookmarked).toBe(false); + expect(component.editable).toBe(false); + expect(component.added).toBe(false); + }); +}); \ No newline at end of file diff --git a/libs/app/recipe/ui/src/recipe-card/recipe-card.component.ts b/libs/app/recipe/ui/src/recipe-card/recipe-card.component.ts index 2b0d009c..dfdd4ea4 100644 --- a/libs/app/recipe/ui/src/recipe-card/recipe-card.component.ts +++ b/libs/app/recipe/ui/src/recipe-card/recipe-card.component.ts @@ -1,26 +1,119 @@ -import { Component, Input } from '@angular/core'; -import { ProfileAPI } from '@fridge-to-plate/app/profile/data-access'; +import { Component, Input, OnInit, NgZone } from '@angular/core'; +import { ProfileState } from '@fridge-to-plate/app/profile/data-access'; +import { AddToMealPlan, IProfile, RemoveFromMealPlan, RemoveSavedRecipe, SaveRecipe } from '@fridge-to-plate/app/profile/utils'; +import { IRecipeDesc } from '@fridge-to-plate/app/recipe/utils'; +import { Select, Store } from '@ngxs/store'; +import { Observable } from 'rxjs'; +import { Router } from '@angular/router'; +import { ShowError } from '@fridge-to-plate/app/error/utils'; +import { IMealPlan } from '@fridge-to-plate/app/meal-plan/utils'; +import { Navigate } from '@ngxs/router-plugin'; +import { LoadRecipe } from '@fridge-to-plate/app/edit-recipe/utils'; @Component({ + // eslint-disable-next-line @angular-eslint/component-selector selector: 'recipe-card', templateUrl: './recipe-card.component.html', styleUrls: ['./recipe-card.component.scss'], }) -export class RecipeCardComponent { - @Input() recipe : any; - @Input() bookmarked : boolean = false; - @Input() profile : any; +export class RecipeCardComponent implements OnInit { + + @Select(ProfileState.getProfile) profile$ !: Observable; + - constructor(private profileAPI: ProfileAPI) {} + @Input() recipe !: any; + bookmarked = false; + editable = true; + added = false; + showMenu = false; + mealType!: "Breakfast" | "Lunch" | "Dinner" | "Snack"; + + constructor(private store: Store, private router: Router, private ngZone: NgZone ) {} + + ngOnInit(): void { + this.profile$.subscribe(profile => { + if (profile !== null && this.recipe !== undefined) { + this.bookmarked = profile.savedRecipes.some((object) => object.recipeId === this.recipe.recipeId); + this.editable = profile.createdRecipes.some((object) => object.recipeId === this.recipe.recipeId); + this.added = this.checkMealPlan(profile.currMealPlan); + } else { + this.bookmarked = false; + this.editable = false; + this.added = false; + } + }); + } changeSaved() { + if (this.bookmarked) { + this.store.dispatch(new RemoveSavedRecipe(this.recipe as IRecipeDesc)); + } else { + this.store.dispatch(new SaveRecipe(this.recipe as IRecipeDesc)); + } + this.bookmarked = !this.bookmarked; + } + + edit() { + if(!this.recipe){ + this.store.dispatch(new ShowError('ERROR: No recipe available to edit.')) + return; + } + this.store.dispatch(new LoadRecipe(this.recipe.recipeId)) + } + + toggleMealPlan() { + this.showMenu = !this.showMenu; + } + + addToMealPlan(meal: string) { + if(!this.recipe) { + this.store.dispatch(new ShowError('ERROR: No recipe available to add to meal plan.')) + return; + } + + this.mealType = meal as "Breakfast" | "Lunch" | "Dinner" | "Snack" + + this.store.dispatch(new AddToMealPlan(this.recipe, this.mealType)); + this.added = true; + } - if (!this.bookmarked) { - this.profile.saved_recipes = this.profile.saved_recipes.filter((item: any) => item !== this.recipe ); - this.profileAPI.editProfile(this.profile); + removeFromMealPlan() { + if(!this.recipe) { + this.store.dispatch(new ShowError('ERROR: No recipe available to remove from meal plan.')) + return; } + + this.store.dispatch(new RemoveFromMealPlan(this.recipe.recipeId)); + this.added = false; + } + + checkMealPlan(mealPlan : IMealPlan | null): boolean { + if (mealPlan === null) { + return false; + } + + if(mealPlan.breakfast?.recipeId === this.recipe.recipeId) { + return true; + } + + if(mealPlan.lunch?.recipeId === this.recipe.recipeId) { + return true; + } + + if(mealPlan.dinner?.recipeId === this.recipe.recipeId) { + return true; + } + + if(mealPlan.snack?.recipeId === this.recipe.recipeId) { + return true; + } + + return false; } + navigateToRecipe() { + this.store.dispatch(new Navigate([`/recipe/${this.recipe.recipeId}`])) + } } diff --git a/libs/app/recipe/ui/src/recipe.module.ts b/libs/app/recipe/ui/src/recipe.module.ts index 8c33253d..e0659e1c 100644 --- a/libs/app/recipe/ui/src/recipe.module.ts +++ b/libs/app/recipe/ui/src/recipe.module.ts @@ -2,13 +2,31 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RecipeCardComponent } from './recipe-card/recipe-card.component'; import { IonicModule } from '@ionic/angular'; +import { TempRecipeCardComponent } from './temp-recipe-card/temp-recipe-card.component'; +import { ProfileDataAccessModule } from '@fridge-to-plate/app/profile/data-access'; +import { FormsModule } from '@angular/forms'; +import { MealPlanModalComponent } from './meal-plan-modal/meal-plan-modal.component'; +import { NgxsModule } from '@ngxs/store'; +// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries +import { RecipeState } from '../../data-access/src/recipe.state'; @NgModule({ imports: [ - CommonModule, + CommonModule, IonicModule, + ProfileDataAccessModule, + + FormsModule, + NgxsModule.forFeature([RecipeState]) + ], + declarations: [ + RecipeCardComponent, + TempRecipeCardComponent, + MealPlanModalComponent + ], + exports: [ + RecipeCardComponent, + TempRecipeCardComponent, ], - declarations: [RecipeCardComponent], - exports: [RecipeCardComponent] }) export class RecipeUIModule {} diff --git a/libs/app/recipe/ui/src/temp-recipe-card/temp-recipe-card.component.html b/libs/app/recipe/ui/src/temp-recipe-card/temp-recipe-card.component.html new file mode 100644 index 00000000..314ae586 --- /dev/null +++ b/libs/app/recipe/ui/src/temp-recipe-card/temp-recipe-card.component.html @@ -0,0 +1,6 @@ +
+ +
+

No Recipe Selected

+
+
diff --git a/libs/app/recipe/ui/src/temp-recipe-card/temp-recipe-card.component.scss b/libs/app/recipe/ui/src/temp-recipe-card/temp-recipe-card.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/libs/app/recipe/ui/src/temp-recipe-card/temp-recipe-card.component.spec.ts b/libs/app/recipe/ui/src/temp-recipe-card/temp-recipe-card.component.spec.ts new file mode 100644 index 00000000..e768bb05 --- /dev/null +++ b/libs/app/recipe/ui/src/temp-recipe-card/temp-recipe-card.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TempRecipeCardComponent } from './temp-recipe-card.component'; + +describe('TempRecipeCardComponent', () => { + let component: TempRecipeCardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TempRecipeCardComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TempRecipeCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/app/recipe/ui/src/temp-recipe-card/temp-recipe-card.component.ts b/libs/app/recipe/ui/src/temp-recipe-card/temp-recipe-card.component.ts new file mode 100644 index 00000000..57c1e175 --- /dev/null +++ b/libs/app/recipe/ui/src/temp-recipe-card/temp-recipe-card.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'temp-recipe-card', + templateUrl: './temp-recipe-card.component.html', + styleUrls: ['./temp-recipe-card.component.scss'], +}) +export class TempRecipeCardComponent {} diff --git a/libs/app/recipe/utils/src/index.ts b/libs/app/recipe/utils/src/index.ts index 95786098..e26bb568 100644 --- a/libs/app/recipe/utils/src/index.ts +++ b/libs/app/recipe/utils/src/index.ts @@ -1 +1,2 @@ export * from './interfaces'; +export * from './recipe.actions'; diff --git a/libs/app/recipe/utils/src/interfaces/index.ts b/libs/app/recipe/utils/src/interfaces/index.ts index 2dd4f1ad..67e79a8e 100644 --- a/libs/app/recipe/utils/src/interfaces/index.ts +++ b/libs/app/recipe/utils/src/interfaces/index.ts @@ -1,2 +1 @@ -export * from './recipe-step.interface'; export * from './recipe.interface'; \ No newline at end of file diff --git a/libs/app/recipe/utils/src/interfaces/recipe-step.interface.ts b/libs/app/recipe/utils/src/interfaces/recipe-step.interface.ts deleted file mode 100644 index 9d04e750..00000000 --- a/libs/app/recipe/utils/src/interfaces/recipe-step.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface IRecipeStep { - instructionHeading: string; - instructionBody: string; - // stepDuration?: number; -} diff --git a/libs/app/recipe/utils/src/interfaces/recipe.interface.ts b/libs/app/recipe/utils/src/interfaces/recipe.interface.ts index b6ff2d95..879035ca 100644 --- a/libs/app/recipe/utils/src/interfaces/recipe.interface.ts +++ b/libs/app/recipe/utils/src/interfaces/recipe.interface.ts @@ -1,15 +1,21 @@ import { IIngredient } from '@fridge-to-plate/app/ingredient/utils'; -import { IRecipeStep } from './recipe-step.interface'; +import { IReview } from '@fridge-to-plate/app/review/utils'; -export interface IRecipe { +export interface IRecipeDesc { recipeId?: string; name: string; + tags: string[]; + difficulty: 'Easy' | 'Medium' | 'Hard'; recipeImage: string; +} + +export interface IRecipe extends IRecipeDesc { + description: string; + servings: number; + prepTime: number; + meal: 'Breakfast' | 'Lunch' | 'Dinner' | 'Snack' | 'Dessert'; ingredients: IIngredient[]; - instructions: IRecipeStep[]; - rating?: number; - difficulty: 'easy' | 'medium' | 'hard'; - prepTime?: number; - numberOfServings?: number; - tags?: string[]; + steps: string[]; + creator: string; + reviews?: IReview[]; } diff --git a/libs/app/recipe/utils/src/recipe.actions.ts b/libs/app/recipe/utils/src/recipe.actions.ts new file mode 100644 index 00000000..ac78eb46 --- /dev/null +++ b/libs/app/recipe/utils/src/recipe.actions.ts @@ -0,0 +1,32 @@ +import { IReview } from "@fridge-to-plate/app/review/utils"; +import { IRecipe } from "./interfaces"; + +export class DeleteRecipe { + static readonly type = "[EditRecipe] DeleteRecipe"; + constructor(public readonly recipeId: string) {} +} + +export class CreateRecipe { + static readonly type = "[Create] CreateRecipe"; + constructor(public readonly recipe: IRecipe) {} +} + +export class RetrieveRecipe { + static readonly type = "[Retrieve] RetrieveRecipe"; + constructor(public readonly recipeId: string) {} +} + +export class UpdateRecipe { + static readonly type = '[Recipe] Update Recipe'; + constructor(public readonly recipe: IRecipe) {} +} + +export class AddReview { + static readonly type = '[Recipe] Add Recipe Review'; + constructor(public readonly review: IReview) {} +} + +export class DeleteReview { + static readonly type = '[Recipe] Delete Recipe Review'; + constructor(public readonly reviewId: string) {} +} diff --git a/libs/app/recommend/data-access/src/index.ts b/libs/app/recommend/data-access/src/index.ts index a9b66bca..cf1b69d2 100644 --- a/libs/app/recommend/data-access/src/index.ts +++ b/libs/app/recommend/data-access/src/index.ts @@ -1,3 +1,4 @@ export * from './recommend.module'; -export * from './store.state'; +export * from './recommend.state'; export * from './ingredients.mock'; +export * from './recommend.api'; \ No newline at end of file diff --git a/libs/app/recommend/data-access/src/ingredients.mock.ts b/libs/app/recommend/data-access/src/ingredients.mock.ts index 76c0fa8c..5e5310cb 100644 --- a/libs/app/recommend/data-access/src/ingredients.mock.ts +++ b/libs/app/recommend/data-access/src/ingredients.mock.ts @@ -1,111 +1,84 @@ -import { IQuantityIngredient } from '@fridge-to-plate/app/ingredient/utils'; +import { IIngredient } from '@fridge-to-plate/app/ingredient/utils'; -export interface IngredientItem { - ingredientId: number; - name: string; - quantity: number; - metadata?: { - amountPerUnit?: number; - unit?: string; - tags?: string[]; - }; -} - -export const ingredientsArray: IQuantityIngredient[] = [ +export const ingredientsArray: IIngredient[] = [ { - ingredientId: '0', name: 'Tomato', - quantity: 2, - scale: 'kg', + amount: 2, + unit: 'kg', }, { - ingredientId: '1', name: 'Onion', - quantity: 1, - scale: 'kg', + amount: 1, + unit: 'kg', }, { - ingredientId: '2', name: 'Rice', - quantity: 3, - scale: 'kg', + amount: 3, + unit: 'kg', }, { - ingredientId: '3', name: 'Chicken', - quantity: 2, - scale: 'kg', + amount: 2, + unit: 'kg', }, { - ingredientId: '4', name: 'Rump Steak', - quantity: 3, - scale: 'kg', + amount: 3, + unit: 'kg', }, { - ingredientId: '5', name: 'Rice', - quantity: 3, - scale: 'kg', + amount: 3, + unit: 'kg', }, { - ingredientId: '6', name: 'Flour', - quantity: 2, - scale: 'kg', + amount: 2, + unit: 'kg', }, { - ingredientId: '7', name: 'Egg', - quantity: 500, - scale: 'g', + amount: 500, + unit: 'g', }, { - ingredientId: '8', name: 'Peppers', - quantity: 2, - scale: 'kg', + amount: 2, + unit: 'kg', }, { - ingredientId: '9', name: 'Sunflower Oil', - quantity: 2, - scale: 'l', + amount: 2, + unit: 'l', }, { - ingredientId: '10', name: 'Milk', - quantity: 4, - scale: 'l', + amount: 4, + unit: 'l', }, { - ingredientId: '11', name: 'Soy Sauce', - quantity: 500, - scale: 'ml', + amount: 500, + unit: 'ml', }, { - ingredientId: '12', name: 'Beef Stock', - quantity: 200, - scale: 'ml', + amount: 200, + unit: 'ml', }, { - ingredientId: '13', name: 'Pasta', - quantity: 2, - scale: 'kg', + amount: 2, + unit: 'kg', }, { - ingredientId: '14', name: 'Salt', - quantity: 200, - scale: 'g', + amount: 200, + unit: 'g', }, { - ingredientId: '15', name: 'Salmon', - quantity: 1, - scale: 'kg', + amount: 1, + unit: 'kg', }, ]; diff --git a/libs/app/recommend/data-access/src/recipes.mock.ts b/libs/app/recommend/data-access/src/recipes.mock.ts index ee06a167..4ed90b21 100644 --- a/libs/app/recommend/data-access/src/recipes.mock.ts +++ b/libs/app/recommend/data-access/src/recipes.mock.ts @@ -1,157 +1,169 @@ -// import { IIngredient } from '@fridge-to-plate/app/ingredient/utils'; -// import { IRecipe } from '@fridge-to-plate/app/recipe/utils'; +import { IIngredient } from '@fridge-to-plate/app/ingredient/utils'; +import {IRecipe} from "@fridge-to-plate/app/recipe/utils"; -// export const recipeArray: IRecipe[] = [ -// { -// id: '0', -// name: 'Beef Stew', -// recipeImage: 'https://source.unsplash.com/800x800/?food', -// ingredients: [], -// difficulty: 'medium', -// steps: [ -// { -// instructionHeading: 'Prepare vegatables', -// instructionBody: 'Take vegetables and wash thoroughly', -// stepDuration: 2, -// }, -// ], -// meta: { -// prepTime: 60, -// numberOfServings: 4, -// tags: ['Beef', 'Protien'], -// }, -// }, -// { -// id: '1', -// name: 'Omelette', -// recipeImage: 'https://source.unsplash.com/800x800/?food', -// ingredients: [], -// difficulty: 'easy', -// steps: [ -// { -// instructionHeading: 'Beat eggs', -// instructionBody: 'Crack eggs into container and beat until mixed', -// stepDuration: 2, -// }, -// ], -// meta: { -// prepTime: 10, -// numberOfServings: 1, -// tags: ['Egg', 'Protien'], -// }, -// }, -// { -// id: '2', -// name: 'Greek Salad', -// recipeImage: 'https://source.unsplash.com/800x800/?food', -// ingredients: [], -// difficulty: 'easy', -// steps: [ -// { -// instructionHeading: 'Prepare vegatables', -// instructionBody: 'Take vegetables and wash thoroughly', -// stepDuration: 2, -// }, -// ], -// meta: { -// prepTime: 10, -// numberOfServings: 4, -// tags: ['Vegetarian', 'Salad', 'Leafy'], -// }, -// }, -// { -// id: '3', -// name: 'Vegan Pizza', -// recipeImage: 'https://source.unsplash.com/800x800/?food', -// ingredients: [], -// difficulty: 'hard', -// steps: [ -// { -// instructionHeading: 'Prepare vegatables', -// instructionBody: 'Take vegetables and wash thoroughly', -// stepDuration: 2, -// }, -// ], -// meta: { -// prepTime: 60, -// numberOfServings: 4, -// tags: ['Vegetarian', 'Vegan'], -// }, -// }, -// { -// id: '4', -// name: 'Chow Mein', -// recipeImage: 'https://source.unsplash.com/800x800/?food', -// ingredients: [], -// difficulty: 'hard', -// steps: [ -// { -// instructionHeading: 'Prepare vegatables', -// instructionBody: 'Take vegetables and wash thoroughly', -// stepDuration: 2, -// }, -// ], -// meta: { -// prepTime: 60, -// numberOfServings: 4, -// tags: ['Asian', 'Protien'], -// }, -// }, -// { -// id: '5', -// name: 'Shrimp Pasta', -// recipeImage: 'https://source.unsplash.com/800x800/?food', -// ingredients: [], -// difficulty: 'hard', -// steps: [ -// { -// instructionHeading: 'Prepare vegatables', -// instructionBody: 'Take vegetables and wash thoroughly', -// stepDuration: 2, -// }, -// ], -// meta: { -// prepTime: 60, -// numberOfServings: 4, -// tags: ['Pasta', 'Seafood'], -// }, -// }, -// { -// id: '6', -// name: 'Cheeseburger', -// recipeImage: 'https://source.unsplash.com/800x800/?food', -// ingredients: [], -// difficulty: 'medium', -// steps: [ -// { -// instructionHeading: 'Prepare vegatables', -// instructionBody: 'Take vegetables and wash thoroughly', -// stepDuration: 2, -// }, -// ], -// meta: { -// prepTime: 60, -// numberOfServings: 4, -// tags: ['Carbs', 'Beef'], -// }, -// }, -// { -// id: '7', -// name: 'Tacos', -// recipeImage: 'https://source.unsplash.com/800x800/?food', -// ingredients: [], -// difficulty: 'hard', -// steps: [ -// { -// instructionHeading: 'Prepare vegatables', -// instructionBody: 'Take vegetables and wash thoroughly', -// stepDuration: 2, -// }, -// ], -// meta: { -// prepTime: 60, -// numberOfServings: 4, -// tags: ['Spanish', 'Snack'], -// }, -// }, -// ]; +/* + + description: string; + servings: number; + prepTime: number; + meal: 'Breakfast' | 'Lunch' | 'Dinner' | 'Snack' | 'Dessert'; + ingredients: IIngredient[]; + steps: string[]; + creator: string; + reviews?: IReview[]; + + */ +export const recipeArray: IRecipe[] = [ + { + id: '0', + name: 'Beef Stew', + recipeImage: 'https://source.unsplash.com/800x800/?food', + ingredients: [], + difficulty: 'Medium', + steps: [ + { + instructionHeading: 'Prepare vegatables', + instructionBody: 'Take vegetables and wash thoroughly', + stepDuration: 2, + }, + ], + meta: { + prepTime: 60, + servings: 4, + tags: ['Beef', 'Protien'], + }, + }, + { + id: '1', + name: 'Omelette', + recipeImage: 'https://source.unsplash.com/800x800/?food', + ingredients: [], + difficulty: 'easy', + steps: [ + { + instructionHeading: 'Beat eggs', + instructionBody: 'Crack eggs into container and beat until mixed', + stepDuration: 2, + }, + ], + meta: { + prepTime: 10, + servings: 1, + tags: ['Egg', 'Protien'], + }, + }, + { + id: '2', + name: 'Greek Salad', + recipeImage: 'https://source.unsplash.com/800x800/?food', + ingredients: [], + difficulty: 'easy', + steps: [ + { + instructionHeading: 'Prepare vegatables', + instructionBody: 'Take vegetables and wash thoroughly', + stepDuration: 2, + }, + ], + meta: { + prepTime: 10, + servings: 4, + tags: ['Vegetarian', 'Salad', 'Leafy'], + }, + }, + { + id: '3', + name: 'Vegan Pizza', + recipeImage: 'https://source.unsplash.com/800x800/?food', + ingredients: [], + difficulty: 'hard', + steps: [ + { + instructionHeading: 'Prepare vegatables', + instructionBody: 'Take vegetables and wash thoroughly', + stepDuration: 2, + }, + ], + meta: { + prepTime: 60, + servings: 4, + tags: ['Vegetarian', 'Vegan'], + }, + }, + { + id: '4', + name: 'Chow Mein', + recipeImage: 'https://source.unsplash.com/800x800/?food', + ingredients: [], + difficulty: 'hard', + steps: [ + { + instructionHeading: 'Prepare vegatables', + instructionBody: 'Take vegetables and wash thoroughly', + stepDuration: 2, + }, + ], + meta: { + prepTime: 60, + servings: 4, + tags: ['Asian', 'Protien'], + }, + }, + { + id: '5', + name: 'Shrimp Pasta', + recipeImage: 'https://source.unsplash.com/800x800/?food', + ingredients: [], + difficulty: 'hard', + steps: [ + { + instructionHeading: 'Prepare vegatables', + instructionBody: 'Take vegetables and wash thoroughly', + stepDuration: 2, + }, + ], + meta: { + prepTime: 60, + servings: 4, + tags: ['Pasta', 'Seafood'], + }, + }, + { + id: '6', + name: 'Cheeseburger', + recipeImage: 'https://source.unsplash.com/800x800/?food', + ingredients: [], + difficulty: 'medium', + steps: [ + { + instructionHeading: 'Prepare vegatables', + instructionBody: 'Take vegetables and wash thoroughly', + stepDuration: 2, + }, + ], + meta: { + prepTime: 60, + servings: 4, + tags: ['Carbs', 'Beef'], + }, + }, + { + id: '7', + name: 'Tacos', + recipeImage: 'https://source.unsplash.com/800x800/?food', + ingredients: [], + difficulty: 'hard', + steps: [ + { + instructionHeading: 'Prepare vegatables', + instructionBody: 'Take vegetables and wash thoroughly', + stepDuration: 2, + }, + ], + meta: { + prepTime: 60, + servings: 4, + tags: ['Spanish', 'Snack'], + }, + }, +]; diff --git a/libs/app/recommend/data-access/src/recommend.api.ts b/libs/app/recommend/data-access/src/recommend.api.ts index 6477edbd..29cd7644 100644 --- a/libs/app/recommend/data-access/src/recommend.api.ts +++ b/libs/app/recommend/data-access/src/recommend.api.ts @@ -5,25 +5,10 @@ import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, Observable, catchError, switchMap } from 'rxjs'; import { Injectable } from '@angular/core'; import { IProfile } from '@fridge-to-plate/app/profile/utils'; -export interface IResponse { - status: number; - message: string; - data: {}; -} - -export interface IngredientsResponse extends IResponse { - data: { - ingredientsList: IIngredient[]; - }; -} +import { IRecommend } from '@fridge-to-plate/app/recommend/utils'; +import { environment } from '@fridge-to-plate/app/environments/utils'; -export interface DietResponse extends IResponse { - data: { - dietList: string[]; - }; -} - -const baseUrl = 'http://localhost:5000/'; +const baseUrl = environment.API_URL + '/recommend'; @Injectable({ providedIn: 'root', @@ -49,25 +34,12 @@ export class RecommendApi { removeIngredient(ingredient: IIngredient) { return ingredientsArray.filter( - (ingredientItem) => - ingredientItem.ingredientId !== ingredient.ingredientId + (ingredientItem) => ingredientItem.name !== ingredient.name ); } //Step 2 getDietList(): Observable { - //TODO:Comment out when backend connected. - // const req: Observable = this.httpClient - // .get('diet') - // .pipe( - // switchMap((res: IngredientsResponse) => { - // return res.data.ingredientsList ?? ingredientsArray; - // }), - // catchError(async (error) => { - // console.log('An error has occured: ', error); - // return error; - // }) - // ); const dietList = ['Vegan', 'Vegetarian', 'Paleo-tonic', 'Ketogenic']; const req = new BehaviorSubject(dietList); @@ -76,19 +48,30 @@ export class RecommendApi { } //Step 3 - getRecommendations(recomendationParams: {}): Observable { - const req: Observable = this.httpClient - .get(`${baseUrl}recommend`) - .pipe( - switchMap((res: IRecipe[]) => { - return new BehaviorSubject(res); - }), - catchError(async (error) => { - console.log('An error has occured: ', error); - return error; - }) - ); + getRecommendations(recomendationParams: IRecommend): Observable { + const req: Observable = this.httpClient.post( + baseUrl, + recomendationParams + ); + return req; + } + + updateRecommendations( + newRecommendations: IRecommend + ): Observable { + const req: Observable = this.httpClient.put( + `${baseUrl}/${newRecommendations.username}`, + newRecommendations + ); return req; } + + getUpdatedPreferences(username: string): Observable { + return this.httpClient.get(`${baseUrl}/${username}`); + } + + addPreferences(preferences: IRecommend): Observable { + return this.httpClient.post(`${baseUrl}/create`, preferences); + } } diff --git a/libs/app/recommend/data-access/src/recommend.module.ts b/libs/app/recommend/data-access/src/recommend.module.ts index b451b345..86d00624 100644 --- a/libs/app/recommend/data-access/src/recommend.module.ts +++ b/libs/app/recommend/data-access/src/recommend.module.ts @@ -1,8 +1,13 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { RecommendApi } from './recommend.api'; +import {NgxsModule} from "@ngxs/store"; +import {RecommendState} from "./recommend.state"; +import { ProfileState } from "@fridge-to-plate/app/profile/data-access"; @NgModule({ - imports: [CommonModule], + imports: [ + CommonModule, + NgxsModule.forFeature([RecommendState, ProfileState]) + ], }) export class RecommendDataAccessModule {} diff --git a/libs/app/recommend/data-access/src/recommend.state.ts b/libs/app/recommend/data-access/src/recommend.state.ts new file mode 100644 index 00000000..174a26a1 --- /dev/null +++ b/libs/app/recommend/data-access/src/recommend.state.ts @@ -0,0 +1,318 @@ +import { Injectable } from '@angular/core'; +import { + Action, + Select, + Selector, + State, + StateContext, + Store, +} from '@ngxs/store'; +import { + AddIngredient, + AddRecommendation, + ClearRecommend, + GetRecipeRecommendations, + GetUpdatedRecommendation, + RemoveIngredient, + UpdateIngredients, + UpdateRecipePreferences, + UpdateRecipeRecommendations, +} from '@fridge-to-plate/app/recommend/utils'; +import { IIngredient } from '@fridge-to-plate/app/ingredient/utils'; +import { IRecipe } from '@fridge-to-plate/app/recipe/utils'; +import { RecommendApi } from './recommend.api'; +import { + IRecipePreferences, + IRecommend, +} from '@fridge-to-plate/app/recommend/utils'; +import { ShowError } from '@fridge-to-plate/app/error/utils'; +import { environment } from '@fridge-to-plate/app/environments/utils'; +import { ProfileState } from '@fridge-to-plate/app/profile/data-access'; +import { Observable } from 'rxjs'; +import { + IProfile, +} from '@fridge-to-plate/app/profile/utils'; + +export interface RecommendStateModel { + recommendRequest: IRecommend; + recipes: IRecipe[]; +} +@State({ + name: 'recommend', + defaults: { + recommendRequest: + environment.TYPE === 'production' + ? { + username: 'joe', + ingredients: [], + recipePreferences: { + keywords: [], + difficulty: '', + rating: '', + meal: '', + servings: '', + prepTime: '', + }, + } + : { + username: 'joe', + ingredients: [ + { + name: 'Tomato', + amount: 2, + unit: 'kg', + }, + { + name: 'Onion', + amount: 1, + unit: 'kg', + }, + { + name: 'Rice', + amount: 3, + unit: 'kg', + }, + { + name: 'Chicken', + amount: 2, + unit: 'kg', + }, + { + name: 'Rump Steak', + amount: 3, + unit: 'kg', + }, + { + name: 'Rice', + amount: 3, + unit: 'kg', + }, + { + name: 'Flour', + amount: 2, + unit: 'kg', + }, + { + name: 'Egg', + amount: 500, + unit: 'g', + }, + { + name: 'Peppers', + amount: 2, + unit: 'kg', + }, + { + name: 'Sunflower Oil', + amount: 2, + unit: 'l', + }, + { + name: 'Milk', + amount: 4, + unit: 'l', + }, + { + name: 'Soy Sauce', + amount: 500, + unit: 'ml', + }, + { + name: 'Beef Stock', + amount: 200, + unit: 'ml', + }, + { + name: 'Pasta', + amount: 2, + unit: 'kg', + }, + { + name: 'Salt', + amount: 200, + unit: 'g', + }, + { + name: 'Salmon', + amount: 1, + unit: 'kg', + }, + ], + recipePreferences: { + keywords: [], + difficulty: 'Easy', + rating: '', + meal: '', + servings: '', + prepTime: '30 - 60 Minutes', + }, + }, + recipes: [], + }, +}) +@Injectable() +export class RecommendState { + constructor(private recommendApi: RecommendApi, private store: Store) {} + + @Select(ProfileState.getProfile) profile$!: Observable; + + @Selector() + static getIngredients(state: RecommendStateModel): IIngredient[] { + return state.recommendRequest.ingredients; + } + + @Selector() + static getRecipePreferences(state: RecommendStateModel): IRecipePreferences { + return state.recommendRequest.recipePreferences; + } + + @Selector() + static getRecipes(state: RecommendStateModel): IRecipe[] { + return state.recipes; + } + + @Action(RemoveIngredient) + removeIngredient( + { patchState, getState }: StateContext, + { ingredient }: RemoveIngredient + ) { + const updatedRecommendRequest = getState().recommendRequest; + + if (updatedRecommendRequest) { + updatedRecommendRequest.ingredients = + updatedRecommendRequest.ingredients.filter((item) => { + return item.name !== ingredient.name; + }); + patchState({ + recommendRequest: updatedRecommendRequest, + }); + + //CALL API + this.store.dispatch( + new UpdateRecipeRecommendations(updatedRecommendRequest) + ); + } + } + + @Action(AddIngredient) + addIngredient( + { patchState, getState }: StateContext, + { ingredient }: AddIngredient + ) { + const updatedRecommendRequest = getState().recommendRequest; + + if (updatedRecommendRequest) { + for (let i = 0; i < updatedRecommendRequest.ingredients.length; i++) { + if (updatedRecommendRequest.ingredients[i].name === ingredient.name) { + this.store.dispatch(new ShowError('Ingredient Already Added')); + return; + } + } + + updatedRecommendRequest.ingredients.push(ingredient); + patchState({ + recommendRequest: updatedRecommendRequest, + }); + + //CALL API - add on remote + this.store.dispatch( + new UpdateRecipeRecommendations(updatedRecommendRequest) + ); + } + } + + @Action(UpdateRecipePreferences) + updatePreferences( + { getState, patchState }: StateContext, + { recipePreference }: UpdateRecipePreferences + ) { + const updatedRecommendRequest = getState().recommendRequest; + + if (updatedRecommendRequest) { + updatedRecommendRequest.recipePreferences = recipePreference; + + patchState({ + recommendRequest: updatedRecommendRequest, + }); + + this.store.dispatch( + new UpdateRecipeRecommendations(updatedRecommendRequest) + ); + } + } + + @Action(GetRecipeRecommendations) + getRecommendations({ + patchState, + getState, + }: StateContext) { + const recommendRequest = getState().recommendRequest; + this.recommendApi.getRecommendations(recommendRequest).subscribe({ + next: (data) => { + patchState({ + recipes: data, + }); + }, + error: (error) => { + this.store.dispatch(new ShowError(error)); + }, + }); + } + + @Action(GetUpdatedRecommendation) + getUpdatedPreferences({ + patchState + }: StateContext, { username } : GetUpdatedRecommendation) { + this.recommendApi + .getUpdatedPreferences(username) + .subscribe((updatedPreferences) => { + this.store.dispatch( + new UpdateIngredients(updatedPreferences.ingredients) + ); + + patchState({ + recommendRequest: updatedPreferences, + }); + }); + } + + @Action(AddRecommendation) + addRecipePreferences({ getState }: StateContext) { + this.profile$.subscribe((currentUserProfile) => { + const currentState = getState(); + + if (currentState) { + const newPreferences: IRecommend = { + ingredients: currentState.recommendRequest.ingredients, + username: currentUserProfile.username, + recipePreferences: currentState.recommendRequest.recipePreferences, + }; + + this.recommendApi.addPreferences(newPreferences).subscribe((res) => {}); + } + }); + } + + @Action(UpdateRecipeRecommendations) + updateRecipeRecommendations({ getState }: StateContext) { + const currentState = getState(); + + if (currentState) { + this.recommendApi + .updateRecommendations(currentState.recommendRequest) + .subscribe({ + error: error => { + this.store.dispatch(new ShowError("Unable to retrieve recommend")) + } + }); + } + } + + @Action(ClearRecommend) + clearRecommendState({ patchState }: StateContext) { + patchState({ + recipes: undefined, + recommendRequest: undefined, + }); + } +} diff --git a/libs/app/recommend/data-access/tsconfig.lib.json b/libs/app/recommend/data-access/tsconfig.lib.json index 372aa1ee..8ee20bc7 100644 --- a/libs/app/recommend/data-access/tsconfig.lib.json +++ b/libs/app/recommend/data-access/tsconfig.lib.json @@ -13,5 +13,5 @@ "jest.config.ts", "src/**/*.test.ts" ], - "include": ["src/**/*.ts", "src/store.state.ts"] + "include": ["src/**/*.ts", "src/store.state.ts", "../utils/src/recommend.actions.ts"] } diff --git a/libs/app/recommend/feature/src/recommend.module.ts b/libs/app/recommend/feature/src/recommend.module.ts index 37180737..cee09ffe 100644 --- a/libs/app/recommend/feature/src/recommend.module.ts +++ b/libs/app/recommend/feature/src/recommend.module.ts @@ -8,11 +8,11 @@ import { ReactiveFormsModule } from '@angular/forms'; import { NzFormModule } from 'ng-zorro-antd/form'; import { NzInputModule } from 'ng-zorro-antd/input'; import { NzIconModule } from 'ng-zorro-antd/icon'; -import { RecommendUIModule } from '../../ui/src'; + import { RecommendPage } from './recommend.page'; -import { RecipeUIModule } from '@fridge-to-plate/app/recipe/ui'; -import { RecommendDataAccessModule } from '../../data-access/src/recommend.module'; import { NavigationBarModule } from '@fridge-to-plate/app/navigation/feature'; +import { RecommendDataAccessModule } from '@fridge-to-plate/app/recommend/data-access'; +import { RecommendUIModule } from '@fridge-to-plate/app/recommend/ui'; @NgModule({ imports: [ diff --git a/libs/app/recommend/feature/src/recommend.page.html b/libs/app/recommend/feature/src/recommend.page.html index 95525333..ac8717c4 100644 --- a/libs/app/recommend/feature/src/recommend.page.html +++ b/libs/app/recommend/feature/src/recommend.page.html @@ -1,4 +1,3 @@ -
- - +
+
diff --git a/libs/app/recommend/feature/src/recommend.page.spec.ts b/libs/app/recommend/feature/src/recommend.page.spec.ts index 04100248..57364c9f 100644 --- a/libs/app/recommend/feature/src/recommend.page.spec.ts +++ b/libs/app/recommend/feature/src/recommend.page.spec.ts @@ -3,8 +3,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RecommendPage } from './recommend.page'; import { IonicModule } from '@ionic/angular'; import { NavigationBarModule } from '@fridge-to-plate/app/navigation/feature'; -import { RecommendUIModule } from '../../ui/src/recommend.module'; +import { RecommendUIModule } from '@fridge-to-plate/app/recommend/ui'; import { HttpClientModule } from '@angular/common/http'; +import { NgxsModule } from '@ngxs/store'; describe('RecipeRecommendationPage', () => { let component: RecommendPage; @@ -17,7 +18,8 @@ describe('RecipeRecommendationPage', () => { IonicModule, NavigationBarModule, RecommendUIModule, - HttpClientModule + HttpClientModule, + NgxsModule.forRoot(), ], }); fixture = TestBed.createComponent(RecommendPage); diff --git a/libs/app/recommend/feature/src/recommend.page.ts b/libs/app/recommend/feature/src/recommend.page.ts index 4fb76d98..baeb4651 100644 --- a/libs/app/recommend/feature/src/recommend.page.ts +++ b/libs/app/recommend/feature/src/recommend.page.ts @@ -1,9 +1,13 @@ -import { Component } from '@angular/core'; -import { NavigationBar } from "@fridge-to-plate/app/navigation/feature"; +import { Component, OnInit } from '@angular/core'; +import { NavigationBar } from '@fridge-to-plate/app/navigation/feature'; +import { Store } from '@ngxs/store'; +import { GetUpdatedRecommendation } from '../../utils/src/recommend.actions'; @Component({ selector: 'app-recipe-recommendation', templateUrl: './recommend.page.html', - styleUrls: ['./recommend.page.scss'] + styleUrls: ['./recommend.page.scss'], }) -export class RecommendPage {} +export class RecommendPage { + constructor(private store: Store) {} +} diff --git a/libs/app/recommend/ui/src/diet-preference-pill-component/diet-preference-pill-component.component.html b/libs/app/recommend/ui/src/diet-preference-pill-component/diet-preference-pill-component.component.html deleted file mode 100644 index ef42e625..00000000 --- a/libs/app/recommend/ui/src/diet-preference-pill-component/diet-preference-pill-component.component.html +++ /dev/null @@ -1,7 +0,0 @@ -
- {{ diet }} -
diff --git a/libs/app/recommend/ui/src/diet-preference-pill-component/diet-preference-pill-component.component.spec.ts b/libs/app/recommend/ui/src/diet-preference-pill-component/diet-preference-pill-component.component.spec.ts deleted file mode 100644 index 4bfe1cd9..00000000 --- a/libs/app/recommend/ui/src/diet-preference-pill-component/diet-preference-pill-component.component.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { IonicModule } from '@ionic/angular'; - -import { DietPreferencePillComponentComponent } from './diet-preference-pill-component.component'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { NzI18nService } from 'ng-zorro-antd/i18n'; - -describe('DietPreferencePillComponentComponent', () => { - let component: DietPreferencePillComponentComponent; - let fixture: ComponentFixture; - - let intlService = NzI18nService; - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [DietPreferencePillComponentComponent], - imports: [IonicModule.forRoot(), HttpClientTestingModule], - providers: [intlService], - }).compileComponents(); - - fixture = TestBed.createComponent(DietPreferencePillComponentComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - })); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/libs/app/recommend/ui/src/diet-preference-pill-component/diet-preference-pill-component.component.ts b/libs/app/recommend/ui/src/diet-preference-pill-component/diet-preference-pill-component.component.ts deleted file mode 100644 index 8dc7ee7d..00000000 --- a/libs/app/recommend/ui/src/diet-preference-pill-component/diet-preference-pill-component.component.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; - -@Component({ - selector: 'fridge-to-plate-diet-preference-pill-component', - templateUrl: './diet-preference-pill-component.component.html', - styleUrls: ['./diet-preference-pill-component.component.css'], -}) -export class DietPreferencePillComponentComponent implements OnInit { - @Input() diet: string | undefined; - - @Output() click = new EventEmitter(); - - isPillSelected = true; - - constructor() {} - - onPillClick(event: Event) { - this.isPillSelected = !this.isPillSelected; - this.click.emit(this.diet); - } - - ngOnInit() {} -} diff --git a/libs/app/recommend/ui/src/item-edit-step/item-edit-step.html b/libs/app/recommend/ui/src/item-edit-step/item-edit-step.html index 5800b2a1..a8fc86f9 100644 --- a/libs/app/recommend/ui/src/item-edit-step/item-edit-step.html +++ b/libs/app/recommend/ui/src/item-edit-step/item-edit-step.html @@ -1,134 +1,52 @@ -
- -
- - -
-
- - Silhouette of mountains - -
-
-

- {{item?.name ?? 'Ingredient'}} -

-

- Quantity: {{ 1 }} -

-
- -
-
-
- -
-
-
-
-
-