From a084586ca4afaf52bc9cb4ea9dbed2ea6ffd6bd1 Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Thu, 18 Apr 2024 22:10:13 +0200 Subject: [PATCH 01/50] [Backend] Add product, category and attribute model & controller --- .../backend/attribute/model/Attribute.java | 35 ++++++++ .../attribute/model/AttributeType.java | 5 ++ .../attribute/model/AttributeValue.java | 34 ++++++++ .../backend/category/model/Category.java | 25 ++++++ .../product/controller/ProductController.java | 79 +++++++++++++++++++ .../backend/product/model/Product.java | 62 +++++++++++++++ .../product/repository/ProductRepository.java | 21 +++++ .../product/service/ProductService.java | 46 +++++++++++ .../backend/security/SecurityConfig.java | 2 +- .../user/controller/UserController.java | 2 + .../backend/user/model/UserEntity.java | 3 + .../backend/user/model/UserViews.java | 9 +++ 12 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/net/pinehaus/backend/attribute/model/Attribute.java create mode 100644 backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeType.java create mode 100644 backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValue.java create mode 100644 backend/src/main/java/net/pinehaus/backend/category/model/Category.java create mode 100644 backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java create mode 100644 backend/src/main/java/net/pinehaus/backend/product/model/Product.java create mode 100644 backend/src/main/java/net/pinehaus/backend/product/repository/ProductRepository.java create mode 100644 backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java create mode 100644 backend/src/main/java/net/pinehaus/backend/user/model/UserViews.java diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/model/Attribute.java b/backend/src/main/java/net/pinehaus/backend/attribute/model/Attribute.java new file mode 100644 index 0000000..98ec6c2 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/model/Attribute.java @@ -0,0 +1,35 @@ +package net.pinehaus.backend.attribute.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@RequiredArgsConstructor +public class Attribute { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private AttributeType type; + + /** + * Options for the attribute. Only used if the attribute type is ENUM. Contains a comma-separated + * list of options. + */ + @Column + private String options; + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeType.java b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeType.java new file mode 100644 index 0000000..ef3d805 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeType.java @@ -0,0 +1,5 @@ +package net.pinehaus.backend.attribute.model; + +public enum AttributeType { + ENUM, NUMBER, BOOLEAN, STRING +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValue.java b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValue.java new file mode 100644 index 0000000..72b56fd --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValue.java @@ -0,0 +1,34 @@ +package net.pinehaus.backend.attribute.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import net.pinehaus.backend.product.model.Product; + +@Entity +@Getter +@Setter +@RequiredArgsConstructor +public class AttributeValue { + + @Id + @ManyToOne + @JoinColumn + private Attribute attribute; + + @Id + @ManyToOne + @JoinColumn + @JsonIgnore + private Product product; + + @Column + private String value; + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/category/model/Category.java b/backend/src/main/java/net/pinehaus/backend/category/model/Category.java new file mode 100644 index 0000000..1feff00 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/category/model/Category.java @@ -0,0 +1,25 @@ +package net.pinehaus.backend.category.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@RequiredArgsConstructor +public class Category { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + @Column(nullable = false) + private String name; + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java b/backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java new file mode 100644 index 0000000..aea62b0 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java @@ -0,0 +1,79 @@ +package net.pinehaus.backend.product.controller; + +import com.fasterxml.jackson.annotation.JsonView; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import net.pinehaus.backend.product.model.Product; +import net.pinehaus.backend.product.service.ProductService; +import net.pinehaus.backend.security.UserPrincipal; +import net.pinehaus.backend.user.model.UserViews; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.server.ResponseStatusException; + +@RestController +@RequestMapping("/product") +@RequiredArgsConstructor +public class ProductController { + + private final ProductService productService; + + @GetMapping("/{id}") + @Operation(summary = "Get a product by ID.", description = "Fetch product by ID, if it exists.") + @ApiResponses({@ApiResponse(responseCode = "200"), @ApiResponse(responseCode = "404")}) + public Product getProduct(@PathVariable int id) { + return productService.getProductById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Product not found")); + } + + @PostMapping + @PreAuthorize("hasAuthority('USER')") + @Operation(summary = "Create a product.", description = "Create a new product.") + @ApiResponses({@ApiResponse(responseCode = "200"), @ApiResponse(responseCode = "409")}) + public Product createProduct(Product product) { + if (productService.existsBySku(product.getSku()) || productService.existsBySlug( + product.getSlug())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Product already exists"); + } + + return productService.createProduct(product); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('USER')") + @JsonView(UserViews.Public.class) + @Operation(summary = "Update a product.", description = "Update an existing product.") + @ApiResponses({@ApiResponse(responseCode = "200"), @ApiResponse(responseCode = "404"), + @ApiResponse(responseCode = "403")}) + public Product updateProduct(@PathVariable int id, + @AuthenticationPrincipal UserPrincipal currentUser, @RequestBody Product productUpdate) { + Optional existingProduct = productService.getProductById(id); + + if (existingProduct.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); + } + + Product product = existingProduct.get(); + + if (!product.getCreatedBy().getId().equals(currentUser.getId()) && !currentUser.isAdmin()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "You are not allowed to update this product"); + } + + return productService.updateProduct(product); + } + + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/model/Product.java b/backend/src/main/java/net/pinehaus/backend/product/model/Product.java new file mode 100644 index 0000000..2997845 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/product/model/Product.java @@ -0,0 +1,62 @@ +package net.pinehaus.backend.product.model; + +import com.fasterxml.jackson.annotation.JsonView; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import net.pinehaus.backend.attribute.model.AttributeValue; +import net.pinehaus.backend.category.model.Category; +import net.pinehaus.backend.user.model.UserEntity; +import net.pinehaus.backend.user.model.UserViews; + +@Entity +@Getter +@Setter +@RequiredArgsConstructor +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + @Column + private String slug; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String description; + + @Column(nullable = false, length = 6) + private String sku; + + @Column(nullable = false) + private int quantity; + + @Column(nullable = false) + private double price; + + @OneToMany(mappedBy = "product", cascade = CascadeType.ALL) + private List attributes; + + @JoinColumn(nullable = false) + @ManyToOne + @Getter(onMethod_ = {@JsonView(UserViews.Public.class)}) + @JsonView(UserViews.Public.class) + private UserEntity createdBy; + + @JoinColumn + @ManyToOne + private Category category; +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/repository/ProductRepository.java b/backend/src/main/java/net/pinehaus/backend/product/repository/ProductRepository.java new file mode 100644 index 0000000..978abde --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/product/repository/ProductRepository.java @@ -0,0 +1,21 @@ +package net.pinehaus.backend.product.repository; + +import java.util.Optional; +import net.pinehaus.backend.product.model.Product; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductRepository extends JpaRepository { + + Optional findBySlug(String slug); + + Optional findById(int id); + + Optional findBySku(String sku); + + boolean existsBySku(String sku); + + boolean existsBySlug(String slug); + + boolean existsById(int id); + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java b/backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java new file mode 100644 index 0000000..7e3d834 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java @@ -0,0 +1,46 @@ +package net.pinehaus.backend.product.service; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import net.pinehaus.backend.product.model.Product; +import net.pinehaus.backend.product.repository.ProductRepository; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + + public Optional getProductBySku(String sku) { + return productRepository.findBySku(sku); + } + + public Optional getProductBySlug(String slug) { + return productRepository.findBySlug(slug); + } + + public Optional getProductById(int id) { + return productRepository.findById(id); + } + + public Product createProduct(Product product) { + return productRepository.save(product); + } + + public boolean existsBySku(String sku) { + return productRepository.existsBySku(sku); + } + + public boolean existsBySlug(String slug) { + return productRepository.existsBySlug(slug); + } + + public boolean existsById(int id) { + return productRepository.existsById(id); + } + + public Product updateProduct(Product product) { + return productRepository.save(product); + } +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/security/SecurityConfig.java b/backend/src/main/java/net/pinehaus/backend/security/SecurityConfig.java index 0f98a71..5ee1eb4 100644 --- a/backend/src/main/java/net/pinehaus/backend/security/SecurityConfig.java +++ b/backend/src/main/java/net/pinehaus/backend/security/SecurityConfig.java @@ -46,7 +46,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(authorizeRequests -> authorizeRequests .requestMatchers("/swagger-ui/**", "/v3/**").permitAll() .requestMatchers("/login/**").permitAll() - .anyRequest().authenticated() + .anyRequest().permitAll() ) .addFilterBefore(pinehausAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/backend/src/main/java/net/pinehaus/backend/user/controller/UserController.java b/backend/src/main/java/net/pinehaus/backend/user/controller/UserController.java index 74d005f..13b2288 100644 --- a/backend/src/main/java/net/pinehaus/backend/user/controller/UserController.java +++ b/backend/src/main/java/net/pinehaus/backend/user/controller/UserController.java @@ -17,6 +17,7 @@ import net.pinehaus.backend.util.ResponseUtilities; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -30,6 +31,7 @@ @RequiredArgsConstructor @Tag(name = "Users", description = "User API") @RequestMapping("/user") +@PreAuthorize("hasAuthority('USER')") public class UserController { private final UserService userService; diff --git a/backend/src/main/java/net/pinehaus/backend/user/model/UserEntity.java b/backend/src/main/java/net/pinehaus/backend/user/model/UserEntity.java index a9fc8e2..53fa0d5 100644 --- a/backend/src/main/java/net/pinehaus/backend/user/model/UserEntity.java +++ b/backend/src/main/java/net/pinehaus/backend/user/model/UserEntity.java @@ -1,6 +1,7 @@ package net.pinehaus.backend.user.model; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonView; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -31,10 +32,12 @@ public class UserEntity { private UUID id; @Column(nullable = false) + @JsonView(UserViews.Public.class) private String firstName; @Column(nullable = false) + @JsonView(UserViews.Public.class) private String lastName; private String username; diff --git a/backend/src/main/java/net/pinehaus/backend/user/model/UserViews.java b/backend/src/main/java/net/pinehaus/backend/user/model/UserViews.java new file mode 100644 index 0000000..c3f3ba6 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/user/model/UserViews.java @@ -0,0 +1,9 @@ +package net.pinehaus.backend.user.model; + +public class UserViews { + + public interface Public { + + } + +} \ No newline at end of file From 7079dc0f8e56af9dbc777089bad256f3c10d5b74 Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Sat, 27 Apr 2024 19:53:31 +0200 Subject: [PATCH 02/50] [Backend] Added Category rest api --- .../authentication/service/TokenService.java | 8 ++-- .../controller/CategoryController.java | 34 +++++++++++++++ .../backend/category/model/Category.java | 2 + .../repository/CategoryRepository.java | 13 ++++++ .../category/service/CategoryService.java | 28 ++++++++++++ .../product/controller/ProductController.java | 43 ++++++++++++++++++- .../product/dto/ProductPageResponse.java | 21 +++++++++ .../backend/product/model/Product.java | 2 +- .../product/repository/ProductRepository.java | 9 ++++ .../product/service/ProductService.java | 26 +++++++++++ 10 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 backend/src/main/java/net/pinehaus/backend/category/controller/CategoryController.java create mode 100644 backend/src/main/java/net/pinehaus/backend/category/repository/CategoryRepository.java create mode 100644 backend/src/main/java/net/pinehaus/backend/category/service/CategoryService.java create mode 100644 backend/src/main/java/net/pinehaus/backend/product/dto/ProductPageResponse.java diff --git a/backend/src/main/java/net/pinehaus/backend/authentication/service/TokenService.java b/backend/src/main/java/net/pinehaus/backend/authentication/service/TokenService.java index a035b89..cd22a2d 100644 --- a/backend/src/main/java/net/pinehaus/backend/authentication/service/TokenService.java +++ b/backend/src/main/java/net/pinehaus/backend/authentication/service/TokenService.java @@ -53,13 +53,13 @@ public boolean validateToken(String authToken) { return true; } catch (MalformedJwtException ex) { - log.error("Invalid JWT token"); + log.info("Invalid JWT token ({})", authToken); } catch (ExpiredJwtException ex) { - log.error("Expired JWT token"); + log.info("Expired JWT token ({})", authToken); } catch (UnsupportedJwtException ex) { - log.error("Unsupported JWT token"); + log.info("Unsupported JWT token ({})", authToken); } catch (IllegalArgumentException ex) { - log.error("JWT claims string is empty."); + log.info("JWT claims string is empty. ({})", authToken); } return false; diff --git a/backend/src/main/java/net/pinehaus/backend/category/controller/CategoryController.java b/backend/src/main/java/net/pinehaus/backend/category/controller/CategoryController.java new file mode 100644 index 0000000..6698c36 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/category/controller/CategoryController.java @@ -0,0 +1,34 @@ +package net.pinehaus.backend.category.controller; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import net.pinehaus.backend.category.model.Category; +import net.pinehaus.backend.category.service.CategoryService; +import org.springframework.http.HttpStatus; +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.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/categories") +@Tag(name = "Categories") +@RequiredArgsConstructor +public class CategoryController { + + private final CategoryService categoryService; + + @GetMapping("/{id}") + public Category getCategory(@PathVariable int id) { + return categoryService.getCategoryById(id).orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Category not found") + ); + } + + @GetMapping + public Iterable getAllCategories() { + return categoryService.getAllCategories(); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/category/model/Category.java b/backend/src/main/java/net/pinehaus/backend/category/model/Category.java index 1feff00..2ce9228 100644 --- a/backend/src/main/java/net/pinehaus/backend/category/model/Category.java +++ b/backend/src/main/java/net/pinehaus/backend/category/model/Category.java @@ -8,6 +8,7 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.Setter; +import org.checkerframework.common.aliasing.qual.Unique; @Entity @Getter @@ -20,6 +21,7 @@ public class Category { private int id; @Column(nullable = false) + @Unique private String name; } \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/category/repository/CategoryRepository.java b/backend/src/main/java/net/pinehaus/backend/category/repository/CategoryRepository.java new file mode 100644 index 0000000..90c5a96 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/category/repository/CategoryRepository.java @@ -0,0 +1,13 @@ +package net.pinehaus.backend.category.repository; + +import java.util.Optional; +import net.pinehaus.backend.category.model.Category; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CategoryRepository extends JpaRepository { + + Optional findById(int id); + + Optional findByName(String name); + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/category/service/CategoryService.java b/backend/src/main/java/net/pinehaus/backend/category/service/CategoryService.java new file mode 100644 index 0000000..0a96217 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/category/service/CategoryService.java @@ -0,0 +1,28 @@ +package net.pinehaus.backend.category.service; + +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import net.pinehaus.backend.category.model.Category; +import net.pinehaus.backend.category.repository.CategoryRepository; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CategoryService { + + private final CategoryRepository categoryRepository; + + public Optional getCategoryById(int id) { + return categoryRepository.findById(id); + } + + public Optional getCategoryByName(String name) { + return categoryRepository.findByName(name); + } + + public List getAllCategories() { + return categoryRepository.findAll(); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java b/backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java index aea62b0..352c619 100644 --- a/backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java +++ b/backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java @@ -4,12 +4,16 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import java.util.Optional; import lombok.RequiredArgsConstructor; +import net.pinehaus.backend.product.dto.ProductPageResponse; import net.pinehaus.backend.product.model.Product; import net.pinehaus.backend.product.service.ProductService; import net.pinehaus.backend.security.UserPrincipal; import net.pinehaus.backend.user.model.UserViews; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -19,16 +23,53 @@ 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.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @RestController -@RequestMapping("/product") +@RequestMapping("/products") +@Tag(name = "Products") @RequiredArgsConstructor public class ProductController { + private final static int PAGE_SIZE = 10; + private final static String DEFAULT_SORT = "asc"; + private final ProductService productService; + @GetMapping + @Operation(summary = "Get paginated products list.", + description = "Fetch paginated products list, optionally filtered by category and price range.") + @ApiResponses({@ApiResponse(responseCode = "200"), @ApiResponse(responseCode = "404")}) + public ProductPageResponse getProduct(@RequestParam(required = false) Optional page, + @RequestParam(required = false) Optional size, + @RequestParam(required = false) Optional sort, + @RequestParam(required = false) Optional categoryId, + @RequestParam(required = false) Optional min, + @RequestParam(required = false) Optional max) { + int _page = page.orElse(0); + int _size = size.orElse(PAGE_SIZE); + Sort.Direction _sort = + sort.orElse(DEFAULT_SORT).equals("asc") ? Sort.Direction.ASC : Sort.Direction.DESC; + + double _min = min.orElse(0d); + double _max = max.orElse(Double.MAX_VALUE); + + Page products; + + if (categoryId.isEmpty()) { + products = productService.getProductsByPriceBetween(_min, _max, _page, _size, _sort); + } else { + products = productService.getProductsByCategoryIdAndPriceBetween(categoryId.get(), _min, _max, + _page, + _size, _sort); + + } + + return new ProductPageResponse(products); + } + @GetMapping("/{id}") @Operation(summary = "Get a product by ID.", description = "Fetch product by ID, if it exists.") @ApiResponses({@ApiResponse(responseCode = "200"), @ApiResponse(responseCode = "404")}) diff --git a/backend/src/main/java/net/pinehaus/backend/product/dto/ProductPageResponse.java b/backend/src/main/java/net/pinehaus/backend/product/dto/ProductPageResponse.java new file mode 100644 index 0000000..7f4be15 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/product/dto/ProductPageResponse.java @@ -0,0 +1,21 @@ +package net.pinehaus.backend.product.dto; + +import java.util.List; +import lombok.Getter; +import lombok.Setter; +import net.pinehaus.backend.product.model.Product; +import org.springframework.data.domain.Page; + +@Getter +@Setter +public class ProductPageResponse { + + private int totalPages; + private List products; + + public ProductPageResponse(Page page) { + this.totalPages = page.getTotalPages(); + this.products = page.getContent(); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/model/Product.java b/backend/src/main/java/net/pinehaus/backend/product/model/Product.java index 2997845..6f6335b 100644 --- a/backend/src/main/java/net/pinehaus/backend/product/model/Product.java +++ b/backend/src/main/java/net/pinehaus/backend/product/model/Product.java @@ -38,7 +38,7 @@ public class Product { @Column(nullable = false) private String description; - @Column(nullable = false, length = 6) + @Column(nullable = false, length = 10) private String sku; @Column(nullable = false) diff --git a/backend/src/main/java/net/pinehaus/backend/product/repository/ProductRepository.java b/backend/src/main/java/net/pinehaus/backend/product/repository/ProductRepository.java index 978abde..cbf8b02 100644 --- a/backend/src/main/java/net/pinehaus/backend/product/repository/ProductRepository.java +++ b/backend/src/main/java/net/pinehaus/backend/product/repository/ProductRepository.java @@ -2,6 +2,8 @@ import java.util.Optional; import net.pinehaus.backend.product.model.Product; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface ProductRepository extends JpaRepository { @@ -12,6 +14,13 @@ public interface ProductRepository extends JpaRepository { Optional findBySku(String sku); + Page findAllByPriceBetween(double min, double max, Pageable pageable); + + Page findAllByCategoryId(int categoryId, Pageable pageable); + + Page findAllByCategoryIdAndPriceBetween(int categoryId, double min, double max, + Pageable pageable); + boolean existsBySku(String sku); boolean existsBySlug(String slug); diff --git a/backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java b/backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java index 7e3d834..2b20685 100644 --- a/backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java +++ b/backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java @@ -4,6 +4,9 @@ import lombok.RequiredArgsConstructor; import net.pinehaus.backend.product.model.Product; import net.pinehaus.backend.product.repository.ProductRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; @Service @@ -20,6 +23,29 @@ public Optional getProductBySlug(String slug) { return productRepository.findBySlug(slug); } + public Page getProductsByCategoryId(int categoryId, int page, int size, + Sort.Direction sortOrder) { + return productRepository.findAllByCategoryId(categoryId, + PageRequest.of(page, size, Sort.by(sortOrder, "price"))); + } + + public Page getProductsByCategoryIdAndPriceBetween(int categoryId, double min, + double max, + int page, int size, Sort.Direction sortOrder) { + return productRepository.findAllByCategoryIdAndPriceBetween(categoryId, min, max, + PageRequest.of(page, size, Sort.by(sortOrder, "price"))); + } + + public Page getProductsByPriceBetween(double min, double max, int page, int size, + Sort.Direction sortOrder) { + return productRepository.findAllByPriceBetween(min, max, + PageRequest.of(page, size, Sort.by(sortOrder, "price"))); + } + + public Page getAllProducts(int page, int size, Sort.Direction sortOrder) { + return productRepository.findAll(PageRequest.of(page, size, Sort.by(sortOrder, "price"))); + } + public Optional getProductById(int id) { return productRepository.findById(id); } From 1cdfd8755463c79e2be88c4ee5d753c185635465 Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Sat, 27 Apr 2024 22:55:49 +0200 Subject: [PATCH 03/50] [Backend] Added thumbnail to products --- .../main/java/net/pinehaus/backend/product/model/Product.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/main/java/net/pinehaus/backend/product/model/Product.java b/backend/src/main/java/net/pinehaus/backend/product/model/Product.java index 6f6335b..fe689f5 100644 --- a/backend/src/main/java/net/pinehaus/backend/product/model/Product.java +++ b/backend/src/main/java/net/pinehaus/backend/product/model/Product.java @@ -47,6 +47,9 @@ public class Product { @Column(nullable = false) private double price; + @Column + private String thumbnail; + @OneToMany(mappedBy = "product", cascade = CascadeType.ALL) private List attributes; From fd081f20a50f434c8a4a5805e727fefc5f4889b9 Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Sun, 28 Apr 2024 12:26:37 +0200 Subject: [PATCH 04/50] [Frontend] Add products page --- .../public/images/banners/products-banner.jpg | Bin 0 -> 147637 bytes frontend/src/app/products/page.tsx | 40 ++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 frontend/public/images/banners/products-banner.jpg create mode 100644 frontend/src/app/products/page.tsx diff --git a/frontend/public/images/banners/products-banner.jpg b/frontend/public/images/banners/products-banner.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9ddfa8259312e7cda45b55b2a176bac175414155 GIT binary patch literal 147637 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 literal 0 HcmV?d00001 diff --git a/frontend/src/app/products/page.tsx b/frontend/src/app/products/page.tsx new file mode 100644 index 0000000..8d2bf01 --- /dev/null +++ b/frontend/src/app/products/page.tsx @@ -0,0 +1,40 @@ +import { ChevronRight } from '@carbon/icons-react' +import { Box, Breadcrumb, BreadcrumbItem, BreadcrumbLink, Flex, Text } from '@chakra-ui/react' +import Image from 'next/image' + +export default function page() { + return ( + <> + + Product page banner + + + + Explore our products + + + }> + + Pinehaus + + + + Products + + + + + + ) +} From 58ddd59699cdd392357b235adaf6ca1d76d35ce8 Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Sun, 28 Apr 2024 12:26:53 +0200 Subject: [PATCH 05/50] [Frontend] Add Product & category model & api --- frontend/src/api/Product/interface.ts | 15 +++++++++++++++ frontend/src/api/Product/repository.ts | 5 +++++ frontend/src/api/Product/routes.ts | 4 ++++ frontend/src/api/routes.ts | 3 +++ frontend/src/model/Category/Category.ts | 4 ++++ frontend/src/model/Category/index.ts | 1 + frontend/src/model/Product/Product.ts | 17 +++++++++++++++++ frontend/src/model/Product/ProductAttribute.ts | 13 +++++++++++++ frontend/src/model/Product/index.ts | 2 ++ frontend/src/utils/api/utils.ts | 4 ++++ 10 files changed, 68 insertions(+) create mode 100644 frontend/src/api/Product/interface.ts create mode 100644 frontend/src/api/Product/repository.ts create mode 100644 frontend/src/api/Product/routes.ts create mode 100644 frontend/src/model/Category/Category.ts create mode 100644 frontend/src/model/Category/index.ts create mode 100644 frontend/src/model/Product/Product.ts create mode 100644 frontend/src/model/Product/ProductAttribute.ts create mode 100644 frontend/src/model/Product/index.ts diff --git a/frontend/src/api/Product/interface.ts b/frontend/src/api/Product/interface.ts new file mode 100644 index 0000000..3382ec4 --- /dev/null +++ b/frontend/src/api/Product/interface.ts @@ -0,0 +1,15 @@ +import { Product } from 'model/Product' + +export interface ProductListFilters { + page?: number + sort?: 'asc' | 'desc' + size?: number + categoryId?: number + min?: number + max?: number +} + +export interface ProductListResponse { + products: Product[] + totalPages: number +} diff --git a/frontend/src/api/Product/repository.ts b/frontend/src/api/Product/repository.ts new file mode 100644 index 0000000..8fba191 --- /dev/null +++ b/frontend/src/api/Product/repository.ts @@ -0,0 +1,5 @@ +import { getJson } from 'utils/api' +import { ProductListFilters, ProductListResponse } from './interface' +import { productList } from './routes' + +export const getProducts = (query: ProductListFilters) => getJson(productList(query)) diff --git a/frontend/src/api/Product/routes.ts b/frontend/src/api/Product/routes.ts new file mode 100644 index 0000000..853c067 --- /dev/null +++ b/frontend/src/api/Product/routes.ts @@ -0,0 +1,4 @@ +import { BASE_API_URL } from 'api/routes' +import { ProductListFilters } from './interface' + +export const productList = (query: ProductListFilters) => `${BASE_API_URL}/products${query}` diff --git a/frontend/src/api/routes.ts b/frontend/src/api/routes.ts index 568853f..8cd9b0f 100644 --- a/frontend/src/api/routes.ts +++ b/frontend/src/api/routes.ts @@ -1,8 +1,11 @@ export const BASE_API_URL = process.env.NEXT_PUBLIC_API_URL export const CORE_URL = process.env.NEXT_PUBLIC_PINEHAUS_URL +export const CDN_URL = 'https://cdn.pinehaus.net' export const googleLoginRedirectRoute = () => `${BASE_API_URL}/login/oauth2/code/google` export const logout = () => `${BASE_API_URL}/login/logout` export const getMe = () => `${BASE_API_URL}/user/me` + +export const image = (image: string) => `${CDN_URL}/${image}` diff --git a/frontend/src/model/Category/Category.ts b/frontend/src/model/Category/Category.ts new file mode 100644 index 0000000..f86e0c3 --- /dev/null +++ b/frontend/src/model/Category/Category.ts @@ -0,0 +1,4 @@ +export interface Category { + id: number + name: string +} diff --git a/frontend/src/model/Category/index.ts b/frontend/src/model/Category/index.ts new file mode 100644 index 0000000..303155d --- /dev/null +++ b/frontend/src/model/Category/index.ts @@ -0,0 +1 @@ +export type { Category } from './Category' diff --git a/frontend/src/model/Product/Product.ts b/frontend/src/model/Product/Product.ts new file mode 100644 index 0000000..ee13434 --- /dev/null +++ b/frontend/src/model/Product/Product.ts @@ -0,0 +1,17 @@ +import { Category } from 'model/Category' +import { User } from 'model/User' +import { ProductAttribute } from './ProductAttribute' + +export interface Product { + id: number + slug: string + name: string + description: string + sku: string + quantity: number + price: number + attributes: ProductAttribute[] + createdBy: User + category: Category + thumbnail: string +} diff --git a/frontend/src/model/Product/ProductAttribute.ts b/frontend/src/model/Product/ProductAttribute.ts new file mode 100644 index 0000000..e855fc8 --- /dev/null +++ b/frontend/src/model/Product/ProductAttribute.ts @@ -0,0 +1,13 @@ +export enum ProductAttributeType { + ENUM = 'ENUM', + STRING = 'STRING', + NUMBER = 'NUMBER', + BOOLEAN = 'BOOLEAN', +} + +export interface ProductAttribute { + id: number + name: string + type: ProductAttributeType + value: string +} diff --git a/frontend/src/model/Product/index.ts b/frontend/src/model/Product/index.ts new file mode 100644 index 0000000..1422706 --- /dev/null +++ b/frontend/src/model/Product/index.ts @@ -0,0 +1,2 @@ +export type { Product } from './Product' +export type { ProductAttribute } from './ProductAttribute' diff --git a/frontend/src/utils/api/utils.ts b/frontend/src/utils/api/utils.ts index cdb92a2..d4608de 100644 --- a/frontend/src/utils/api/utils.ts +++ b/frontend/src/utils/api/utils.ts @@ -72,3 +72,7 @@ export async function postJson(url: string, body?: any, options return parseResponse(response) } + +export const query = (params: Record) => { + return `?${new URLSearchParams(params).toString()}` +} From c3d8ec1bf1f6de4fb0ff2e712c360edeaaec9091 Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Sun, 28 Apr 2024 17:53:41 +0200 Subject: [PATCH 06/50] [Frontend] Product list page, model, reducer --- frontend/next.config.js | 4 ++ frontend/src/api/Product/routes.ts | 3 +- frontend/src/app/products/error.js | 5 +++ frontend/src/app/products/page.tsx | 16 ++++++-- frontend/src/app/profile/page.tsx | 4 +- .../src/components/Product/ProductSlot.tsx | 41 +++++++++++++++++++ frontend/src/components/Product/index.ts | 1 + frontend/src/components/Product/style.ts | 19 +++++++++ frontend/src/model/User/Product/Product.ts | 17 ++++++++ .../model/User/Product/ProductAttribute.ts | 13 ++++++ frontend/src/model/User/Product/index.ts | 2 + frontend/src/modules/Products/Products.tsx | 18 ++++++++ .../components/ProductList/ProductList.tsx | 22 ++++++++++ .../Products/components/ProductList/index.ts | 1 + .../src/modules/Products/components/index.ts | 1 + frontend/src/modules/Products/const.ts | 14 +++++++ frontend/src/modules/Products/context.tsx | 38 +++++++++++++++++ frontend/src/modules/Products/index.ts | 1 + frontend/src/modules/Products/interface.ts | 14 +++++++ .../src/modules/Products/reducer/actions.ts | 9 ++++ .../src/modules/Products/reducer/interface.ts | 12 ++++++ .../src/modules/Products/reducer/reducer.ts | 21 ++++++++++ frontend/src/styles/global/globals.css | 2 +- frontend/src/utils/api/utils.ts | 5 ++- frontend/src/utils/interface.ts | 4 ++ frontend/src/utils/pages.ts | 1 + 26 files changed, 278 insertions(+), 10 deletions(-) create mode 100644 frontend/src/app/products/error.js create mode 100644 frontend/src/components/Product/ProductSlot.tsx create mode 100644 frontend/src/components/Product/index.ts create mode 100644 frontend/src/components/Product/style.ts create mode 100644 frontend/src/model/User/Product/Product.ts create mode 100644 frontend/src/model/User/Product/ProductAttribute.ts create mode 100644 frontend/src/model/User/Product/index.ts create mode 100644 frontend/src/modules/Products/Products.tsx create mode 100644 frontend/src/modules/Products/components/ProductList/ProductList.tsx create mode 100644 frontend/src/modules/Products/components/ProductList/index.ts create mode 100644 frontend/src/modules/Products/components/index.ts create mode 100644 frontend/src/modules/Products/const.ts create mode 100644 frontend/src/modules/Products/context.tsx create mode 100644 frontend/src/modules/Products/index.ts create mode 100644 frontend/src/modules/Products/interface.ts create mode 100644 frontend/src/modules/Products/reducer/actions.ts create mode 100644 frontend/src/modules/Products/reducer/interface.ts create mode 100644 frontend/src/modules/Products/reducer/reducer.ts create mode 100644 frontend/src/utils/interface.ts create mode 100644 frontend/src/utils/pages.ts diff --git a/frontend/next.config.js b/frontend/next.config.js index a98ed79..1e816c6 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -10,6 +10,10 @@ const nextConfig = { protocol: 'https', hostname: 'images.pexels.com', }, + { + protocol: 'https', + hostname: 'cdn.pinehaus.net', + }, ], }, } diff --git a/frontend/src/api/Product/routes.ts b/frontend/src/api/Product/routes.ts index 853c067..4ecd782 100644 --- a/frontend/src/api/Product/routes.ts +++ b/frontend/src/api/Product/routes.ts @@ -1,4 +1,5 @@ import { BASE_API_URL } from 'api/routes' import { ProductListFilters } from './interface' +import { query } from 'utils/api' -export const productList = (query: ProductListFilters) => `${BASE_API_URL}/products${query}` +export const productList = (queryObject: ProductListFilters) => `${BASE_API_URL}/products${query(queryObject)}` diff --git a/frontend/src/app/products/error.js b/frontend/src/app/products/error.js new file mode 100644 index 0000000..2a67aca --- /dev/null +++ b/frontend/src/app/products/error.js @@ -0,0 +1,5 @@ +'use client' + +export default function error() { + return <>Something went wrong :( +} diff --git a/frontend/src/app/products/page.tsx b/frontend/src/app/products/page.tsx index 8d2bf01..416d981 100644 --- a/frontend/src/app/products/page.tsx +++ b/frontend/src/app/products/page.tsx @@ -1,20 +1,22 @@ import { ChevronRight } from '@carbon/icons-react' -import { Box, Breadcrumb, BreadcrumbItem, BreadcrumbLink, Flex, Text } from '@chakra-ui/react' +import { Box, Breadcrumb, BreadcrumbItem, BreadcrumbLink, Container, Flex, Text } from '@chakra-ui/react' +import { Products } from 'modules/Products' import Image from 'next/image' +import { Suspense } from 'react' -export default function page() { +export default function Page() { return ( <> Product page banner @@ -35,6 +37,12 @@ export default function page() { + + + Loading...}> + + + ) } diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index 20142d2..54d19fa 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -57,12 +57,12 @@ export default function ProfilePage() { Profile page banner diff --git a/frontend/src/components/Product/ProductSlot.tsx b/frontend/src/components/Product/ProductSlot.tsx new file mode 100644 index 0000000..5f9c122 --- /dev/null +++ b/frontend/src/components/Product/ProductSlot.tsx @@ -0,0 +1,41 @@ +import { Box, Text } from '@chakra-ui/react' +import { image } from 'api/routes' +import { Product } from 'model/Product' +import Link from 'next/link' +import { productPage } from 'utils/pages' +import { ProductImage } from './style' +import { useProductsState } from 'modules/Products/context' + +export default function ProductSlot({ product }: { product: Product }) { + const { products } = useProductsState() + + return ( + + + + + + + + + {product.name} + + + + {product.description} + + + + €{product.price} + + + + + ) +} diff --git a/frontend/src/components/Product/index.ts b/frontend/src/components/Product/index.ts new file mode 100644 index 0000000..13b2fe7 --- /dev/null +++ b/frontend/src/components/Product/index.ts @@ -0,0 +1 @@ +export { default as ProductSlot } from './ProductSlot' diff --git a/frontend/src/components/Product/style.ts b/frontend/src/components/Product/style.ts new file mode 100644 index 0000000..4867746 --- /dev/null +++ b/frontend/src/components/Product/style.ts @@ -0,0 +1,19 @@ +import Image from 'next/image' +import styled from 'styled-components' + +export const ProductImage = styled(Image)` + object-fit: contain; + object-position: center; + overflow: hidden; + + width: 100%; + height: 100%; + + transition: scale 0.1s ease-in-out; + + &:hover { + scale: 1.1; + + transition: scale 0.1s ease-in-out; + } +` diff --git a/frontend/src/model/User/Product/Product.ts b/frontend/src/model/User/Product/Product.ts new file mode 100644 index 0000000..ee13434 --- /dev/null +++ b/frontend/src/model/User/Product/Product.ts @@ -0,0 +1,17 @@ +import { Category } from 'model/Category' +import { User } from 'model/User' +import { ProductAttribute } from './ProductAttribute' + +export interface Product { + id: number + slug: string + name: string + description: string + sku: string + quantity: number + price: number + attributes: ProductAttribute[] + createdBy: User + category: Category + thumbnail: string +} diff --git a/frontend/src/model/User/Product/ProductAttribute.ts b/frontend/src/model/User/Product/ProductAttribute.ts new file mode 100644 index 0000000..e855fc8 --- /dev/null +++ b/frontend/src/model/User/Product/ProductAttribute.ts @@ -0,0 +1,13 @@ +export enum ProductAttributeType { + ENUM = 'ENUM', + STRING = 'STRING', + NUMBER = 'NUMBER', + BOOLEAN = 'BOOLEAN', +} + +export interface ProductAttribute { + id: number + name: string + type: ProductAttributeType + value: string +} diff --git a/frontend/src/model/User/Product/index.ts b/frontend/src/model/User/Product/index.ts new file mode 100644 index 0000000..1422706 --- /dev/null +++ b/frontend/src/model/User/Product/index.ts @@ -0,0 +1,2 @@ +export type { Product } from './Product' +export type { ProductAttribute } from './ProductAttribute' diff --git a/frontend/src/modules/Products/Products.tsx b/frontend/src/modules/Products/Products.tsx new file mode 100644 index 0000000..807be45 --- /dev/null +++ b/frontend/src/modules/Products/Products.tsx @@ -0,0 +1,18 @@ +import { getProducts } from 'api/Product/repository' +import { ProductList } from './components' + +export default async function Products() { + let products + + try { + products = await getProducts({}) + } catch (e) { + console.error(e) + } + + return ( + <> + + + ) +} diff --git a/frontend/src/modules/Products/components/ProductList/ProductList.tsx b/frontend/src/modules/Products/components/ProductList/ProductList.tsx new file mode 100644 index 0000000..96c8e0e --- /dev/null +++ b/frontend/src/modules/Products/components/ProductList/ProductList.tsx @@ -0,0 +1,22 @@ +'use client' + +import { Flex } from '@chakra-ui/react' +import { ProductSlot } from 'components/Product' +import { Product } from 'model/Product' +import { ProductsProvider } from 'modules/Products/context' + +interface Props { + products: Product[] +} + +export default function ProductList({ products }: Props) { + return ( + + + {products.map(product => ( + + ))} + + + ) +} diff --git a/frontend/src/modules/Products/components/ProductList/index.ts b/frontend/src/modules/Products/components/ProductList/index.ts new file mode 100644 index 0000000..5e7a087 --- /dev/null +++ b/frontend/src/modules/Products/components/ProductList/index.ts @@ -0,0 +1 @@ +export { default } from './ProductList' diff --git a/frontend/src/modules/Products/components/index.ts b/frontend/src/modules/Products/components/index.ts new file mode 100644 index 0000000..0394881 --- /dev/null +++ b/frontend/src/modules/Products/components/index.ts @@ -0,0 +1 @@ +export { default as ProductList } from './ProductList' diff --git a/frontend/src/modules/Products/const.ts b/frontend/src/modules/Products/const.ts new file mode 100644 index 0000000..d98cb13 --- /dev/null +++ b/frontend/src/modules/Products/const.ts @@ -0,0 +1,14 @@ +import { IProductsState } from './interface' + +export const PRODUCT_PAGE_SIZES = [5, 10, 20, 50] + +const DEFAULT_PRODUCT_PAGE_SIZE = PRODUCT_PAGE_SIZES[1] + +export const INITIAL_PRODUCTS_STATE: IProductsState = { + currentPage: 1, + filters: {}, + products: [], + size: DEFAULT_PRODUCT_PAGE_SIZE, + sort: 'asc', + totalPages: 0, +} diff --git a/frontend/src/modules/Products/context.tsx b/frontend/src/modules/Products/context.tsx new file mode 100644 index 0000000..309ed4b --- /dev/null +++ b/frontend/src/modules/Products/context.tsx @@ -0,0 +1,38 @@ +import React, { createContext, useReducer } from 'react' +import { reducer } from './reducer/reducer' +import { IProductsState } from './interface' +import { ProductsActions } from './reducer/interface' +import { Product } from 'model/Product' + +interface IProductsContext { + state: IProductsState + dispatch: React.Dispatch +} + +const ProductsContext = createContext({} as IProductsContext) + +export const ProductsProvider = ({ children, products }: React.PropsWithChildren<{ products: Product[] }>) => { + const [state, dispatch] = useReducer(reducer, { products } as IProductsState) + + return {children} +} + +export const useProductsState = () => { + const context = React.useContext(ProductsContext) + + if (!context) { + throw new Error('useProducts must be used within a ProductsProvider') + } + + return context.state +} + +export const useProductsDispatch = () => { + const context = React.useContext(ProductsContext) + + if (!context) { + throw new Error('useProducts must be used within a ProductsProvider') + } + + return context.dispatch +} diff --git a/frontend/src/modules/Products/index.ts b/frontend/src/modules/Products/index.ts new file mode 100644 index 0000000..26f64fa --- /dev/null +++ b/frontend/src/modules/Products/index.ts @@ -0,0 +1 @@ +export { default as Products } from './Products' diff --git a/frontend/src/modules/Products/interface.ts b/frontend/src/modules/Products/interface.ts new file mode 100644 index 0000000..5903ca0 --- /dev/null +++ b/frontend/src/modules/Products/interface.ts @@ -0,0 +1,14 @@ +import { Product } from 'model/Product' + +export interface IProductsState { + products: Product[] + totalPages: number + currentPage: number + size: number + sort: 'asc' | 'desc' + filters: { + min?: number + max?: number + } + categoryId?: number +} diff --git a/frontend/src/modules/Products/reducer/actions.ts b/frontend/src/modules/Products/reducer/actions.ts new file mode 100644 index 0000000..eb00ba7 --- /dev/null +++ b/frontend/src/modules/Products/reducer/actions.ts @@ -0,0 +1,9 @@ +import { Product } from 'model/Product' +import { SetProductsAction } from './interface' + +/** Products */ + +export const setProducts = (products: Product[], totalPages: number): SetProductsAction => ({ + type: 'SET_PRODUCTS', + payload: { products, totalPages }, +}) diff --git a/frontend/src/modules/Products/reducer/interface.ts b/frontend/src/modules/Products/reducer/interface.ts new file mode 100644 index 0000000..0f68644 --- /dev/null +++ b/frontend/src/modules/Products/reducer/interface.ts @@ -0,0 +1,12 @@ +import { Product } from 'model/Product' +import { Action } from 'utils/interface' + +export type SetProductsAction = Action< + 'SET_PRODUCTS', + { + products: Product[] + totalPages: number + } +> + +export type ProductsActions = SetProductsAction diff --git a/frontend/src/modules/Products/reducer/reducer.ts b/frontend/src/modules/Products/reducer/reducer.ts new file mode 100644 index 0000000..c8d4d16 --- /dev/null +++ b/frontend/src/modules/Products/reducer/reducer.ts @@ -0,0 +1,21 @@ +import { IProductsState } from '../interface' +import { ProductsActions } from './interface' + +export const SET_PRODUCTS = 'SET_PRODUCTS' + +export const reducer = (state: IProductsState, action: ProductsActions): IProductsState => { + switch (action.type) { + case SET_PRODUCTS: { + const { products, totalPages } = action.payload + + return { + ...state, + products, + totalPages, + } + } + + default: + return state + } +} diff --git a/frontend/src/styles/global/globals.css b/frontend/src/styles/global/globals.css index 879715d..d07c217 100644 --- a/frontend/src/styles/global/globals.css +++ b/frontend/src/styles/global/globals.css @@ -1,3 +1,3 @@ html, body { height: 100%; -} \ No newline at end of file +} diff --git a/frontend/src/utils/api/utils.ts b/frontend/src/utils/api/utils.ts index d4608de..66493d6 100644 --- a/frontend/src/utils/api/utils.ts +++ b/frontend/src/utils/api/utils.ts @@ -6,7 +6,6 @@ export function parseResponse(response: Response) { return new Promise(async (resolve, reject) => { try { const contentType = response.headers.get('content-type') - console.log(response) const isSuccessStatus = response.status >= 200 && response.status < 400 if (response.status === 204) { @@ -40,7 +39,7 @@ export function parseResponse(response: Response) { export async function getJson(url: string) { const response = await fetch(url, { - credentials: 'include', + // credentials: 'include', }) return parseResponse(response) @@ -74,5 +73,7 @@ export async function postJson(url: string, body?: any, options } export const query = (params: Record) => { + if (Object.keys(params).length === 0) return '' + return `?${new URLSearchParams(params).toString()}` } diff --git a/frontend/src/utils/interface.ts b/frontend/src/utils/interface.ts new file mode 100644 index 0000000..0f50bdc --- /dev/null +++ b/frontend/src/utils/interface.ts @@ -0,0 +1,4 @@ +export interface Action { + type: K + payload: T +} diff --git a/frontend/src/utils/pages.ts b/frontend/src/utils/pages.ts new file mode 100644 index 0000000..e90eb5f --- /dev/null +++ b/frontend/src/utils/pages.ts @@ -0,0 +1 @@ +export const productPage = (productId: number, productSlug: string) => `/products/${productId}/${productSlug}` From 6c2ab11534e551144e79ad5f6217c00360e2b418 Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Sun, 28 Apr 2024 23:14:53 +0200 Subject: [PATCH 07/50] [Frontend] WIP product page --- frontend/src/api/Product/interface.ts | 2 + frontend/src/api/Product/repository.ts | 8 +- frontend/src/api/Product/routes.ts | 4 +- .../[id]/[[...productInfo]]/layout.tsx | 5 ++ .../products/[id]/[[...productInfo]]/page.tsx | 18 ++++ .../src/app/products/{error.js => error.tsx} | 2 +- frontend/src/app/products/page.tsx | 2 +- .../components/NumberInput/NumberInput.tsx | 28 ++++++ frontend/src/components/NumberInput/index.ts | 1 + .../src/components/Product/ProductSlot.tsx | 21 +++-- frontend/src/modules/Product/ProductPage.tsx | 87 +++++++++++++++++++ frontend/src/modules/Product/index.ts | 1 + .../components/ProductList/ProductList.tsx | 2 +- frontend/src/utils/pages.ts | 4 +- 14 files changed, 169 insertions(+), 16 deletions(-) create mode 100644 frontend/src/app/products/[id]/[[...productInfo]]/layout.tsx create mode 100644 frontend/src/app/products/[id]/[[...productInfo]]/page.tsx rename frontend/src/app/products/{error.js => error.tsx} (61%) create mode 100644 frontend/src/components/NumberInput/NumberInput.tsx create mode 100644 frontend/src/components/NumberInput/index.ts create mode 100644 frontend/src/modules/Product/ProductPage.tsx create mode 100644 frontend/src/modules/Product/index.ts diff --git a/frontend/src/api/Product/interface.ts b/frontend/src/api/Product/interface.ts index 3382ec4..25d06dd 100644 --- a/frontend/src/api/Product/interface.ts +++ b/frontend/src/api/Product/interface.ts @@ -13,3 +13,5 @@ export interface ProductListResponse { products: Product[] totalPages: number } + +export interface GetProductResponse extends Product {} diff --git a/frontend/src/api/Product/repository.ts b/frontend/src/api/Product/repository.ts index 8fba191..2bf231f 100644 --- a/frontend/src/api/Product/repository.ts +++ b/frontend/src/api/Product/repository.ts @@ -1,5 +1,7 @@ import { getJson } from 'utils/api' -import { ProductListFilters, ProductListResponse } from './interface' -import { productList } from './routes' +import { GetProductResponse, ProductListFilters, ProductListResponse } from './interface' +import * as R from './routes' -export const getProducts = (query: ProductListFilters) => getJson(productList(query)) +export const getProducts = (query: ProductListFilters) => getJson(R.getProductList(query)) + +export const getProduct = (id: number) => getJson(R.getProduct(id)) diff --git a/frontend/src/api/Product/routes.ts b/frontend/src/api/Product/routes.ts index 4ecd782..104157a 100644 --- a/frontend/src/api/Product/routes.ts +++ b/frontend/src/api/Product/routes.ts @@ -2,4 +2,6 @@ import { BASE_API_URL } from 'api/routes' import { ProductListFilters } from './interface' import { query } from 'utils/api' -export const productList = (queryObject: ProductListFilters) => `${BASE_API_URL}/products${query(queryObject)}` +export const getProductList = (queryObject: ProductListFilters) => `${BASE_API_URL}/products${query(queryObject)}` + +export const getProduct = (id: number) => `${BASE_API_URL}/products/${id}` diff --git a/frontend/src/app/products/[id]/[[...productInfo]]/layout.tsx b/frontend/src/app/products/[id]/[[...productInfo]]/layout.tsx new file mode 100644 index 0000000..0a100f8 --- /dev/null +++ b/frontend/src/app/products/[id]/[[...productInfo]]/layout.tsx @@ -0,0 +1,5 @@ +import { Suspense } from 'react' + +export default function Layout({ children }: React.PropsWithChildren<{}>) { + return Loading product...}>{children} +} diff --git a/frontend/src/app/products/[id]/[[...productInfo]]/page.tsx b/frontend/src/app/products/[id]/[[...productInfo]]/page.tsx new file mode 100644 index 0000000..b8f8fde --- /dev/null +++ b/frontend/src/app/products/[id]/[[...productInfo]]/page.tsx @@ -0,0 +1,18 @@ +import { getProduct } from 'api/Product/repository' +import { ProductPage } from 'modules/Product' + +interface Props { + params: { + productInfo: string[] + id: string + } +} + +export default async function Page({ params }: Props) { + const [productSlug] = params.productInfo ?? [] + const productId = Number(params.id) + + const product = await getProduct(productId) + + return +} diff --git a/frontend/src/app/products/error.js b/frontend/src/app/products/error.tsx similarity index 61% rename from frontend/src/app/products/error.js rename to frontend/src/app/products/error.tsx index 2a67aca..6bf30c2 100644 --- a/frontend/src/app/products/error.js +++ b/frontend/src/app/products/error.tsx @@ -1,5 +1,5 @@ 'use client' -export default function error() { +export default function Error() { return <>Something went wrong :( } diff --git a/frontend/src/app/products/page.tsx b/frontend/src/app/products/page.tsx index 416d981..7a98896 100644 --- a/frontend/src/app/products/page.tsx +++ b/frontend/src/app/products/page.tsx @@ -22,7 +22,7 @@ export default function Page() { /> - + Explore our products diff --git a/frontend/src/components/NumberInput/NumberInput.tsx b/frontend/src/components/NumberInput/NumberInput.tsx new file mode 100644 index 0000000..a381fc0 --- /dev/null +++ b/frontend/src/components/NumberInput/NumberInput.tsx @@ -0,0 +1,28 @@ +import { Button, HStack, Input, InputProps, UseNumberInputProps, useNumberInput } from '@chakra-ui/react' + +interface Props { + numberInputProps?: UseNumberInputProps + inputProps?: InputProps +} +export default function NumberInput({ numberInputProps, inputProps }: Props) { + const { getInputProps, getIncrementButtonProps, getDecrementButtonProps } = useNumberInput({ + step: 1, + defaultValue: 1, + min: 1, + max: 6, + precision: 0, + ...numberInputProps, + }) + + const inc = getIncrementButtonProps() + const dec = getDecrementButtonProps() + const input = getInputProps() + + return ( + + + + + + ) +} diff --git a/frontend/src/components/NumberInput/index.ts b/frontend/src/components/NumberInput/index.ts new file mode 100644 index 0000000..f493643 --- /dev/null +++ b/frontend/src/components/NumberInput/index.ts @@ -0,0 +1 @@ +export { default } from './NumberInput' diff --git a/frontend/src/components/Product/ProductSlot.tsx b/frontend/src/components/Product/ProductSlot.tsx index 5f9c122..9076906 100644 --- a/frontend/src/components/Product/ProductSlot.tsx +++ b/frontend/src/components/Product/ProductSlot.tsx @@ -1,17 +1,22 @@ -import { Box, Text } from '@chakra-ui/react' +import { Box, Text, useColorModeValue } from '@chakra-ui/react' import { image } from 'api/routes' import { Product } from 'model/Product' import Link from 'next/link' -import { productPage } from 'utils/pages' +import { productPageUrl } from 'utils/pages' import { ProductImage } from './style' -import { useProductsState } from 'modules/Products/context' export default function ProductSlot({ product }: { product: Product }) { - const { products } = useProductsState() + const bottomBoxBg = useColorModeValue('gray.50', 'gray.900') return ( - - + + - + {product.name} - + {product.description} diff --git a/frontend/src/modules/Product/ProductPage.tsx b/frontend/src/modules/Product/ProductPage.tsx new file mode 100644 index 0000000..0d584a7 --- /dev/null +++ b/frontend/src/modules/Product/ProductPage.tsx @@ -0,0 +1,87 @@ +'use client' + +import { ChevronRight } from '@carbon/icons-react' +import { + Box, + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + Button, + Container, + Divider, + Flex, + Text, + useColorModeValue, +} from '@chakra-ui/react' +import { image } from 'api/routes' +import NumberInput from 'components/NumberInput' +import { Product } from 'model/Product' +import Image from 'next/image' +import { categoryPageUrl, productPageUrl } from 'utils/pages' + +interface Props { + product: Product +} + +export default function ProductPage({ product }: Props) { + const breadcrumbBarBg = useColorModeValue('yellow.200', 'orange.700') + + return ( + <> + + + }> + + Pinehaus + + + + Products + + + + + {product.category.name} + + + + + {product.category.name} + + + + + + + + + {product.name} + + + + {product.name} + + {product.price} € + + + + + + {product.description} + + + + + + + + + + + + + + ) +} diff --git a/frontend/src/modules/Product/index.ts b/frontend/src/modules/Product/index.ts new file mode 100644 index 0000000..1af481f --- /dev/null +++ b/frontend/src/modules/Product/index.ts @@ -0,0 +1 @@ +export { default as ProductPage } from './ProductPage' diff --git a/frontend/src/modules/Products/components/ProductList/ProductList.tsx b/frontend/src/modules/Products/components/ProductList/ProductList.tsx index 96c8e0e..1684043 100644 --- a/frontend/src/modules/Products/components/ProductList/ProductList.tsx +++ b/frontend/src/modules/Products/components/ProductList/ProductList.tsx @@ -12,7 +12,7 @@ interface Props { export default function ProductList({ products }: Props) { return ( - + {products.map(product => ( ))} diff --git a/frontend/src/utils/pages.ts b/frontend/src/utils/pages.ts index e90eb5f..7705dc3 100644 --- a/frontend/src/utils/pages.ts +++ b/frontend/src/utils/pages.ts @@ -1 +1,3 @@ -export const productPage = (productId: number, productSlug: string) => `/products/${productId}/${productSlug}` +export const productPageUrl = (productId: number, productSlug: string) => `/products/${productId}/${productSlug}` + +export const categoryPageUrl = (categoryId: number, categorySlug: string) => `/categories/${categoryId}/${categorySlug}` From 177f09372f0b67e3a1ee4e6bccc0a6275c1f847d Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Mon, 29 Apr 2024 18:49:10 +0200 Subject: [PATCH 08/50] [Backend] Added localhost origin --- .../main/java/net/pinehaus/backend/security/SecurityConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/main/java/net/pinehaus/backend/security/SecurityConfig.java b/backend/src/main/java/net/pinehaus/backend/security/SecurityConfig.java index 5ee1eb4..34242b0 100644 --- a/backend/src/main/java/net/pinehaus/backend/security/SecurityConfig.java +++ b/backend/src/main/java/net/pinehaus/backend/security/SecurityConfig.java @@ -36,6 +36,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { config.setAllowCredentials(true); config.addAllowedOrigin(FRONTEND_URL); config.addAllowedOrigin("null"); + config.addAllowedOrigin("http://localhost"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); From 4e1df5bae69fc8c55c015be9a4309c04584fa853 Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Mon, 29 Apr 2024 18:58:52 +0200 Subject: [PATCH 09/50] [Backend] forgot the port --- .../main/java/net/pinehaus/backend/security/SecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/net/pinehaus/backend/security/SecurityConfig.java b/backend/src/main/java/net/pinehaus/backend/security/SecurityConfig.java index 34242b0..d634096 100644 --- a/backend/src/main/java/net/pinehaus/backend/security/SecurityConfig.java +++ b/backend/src/main/java/net/pinehaus/backend/security/SecurityConfig.java @@ -36,7 +36,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { config.setAllowCredentials(true); config.addAllowedOrigin(FRONTEND_URL); config.addAllowedOrigin("null"); - config.addAllowedOrigin("http://localhost"); + config.addAllowedOrigin("http://localhost:3000"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); From a48bd2c2df190e9f9e6e0f68fa0cec1dcabf84b1 Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Mon, 29 Apr 2024 14:27:14 +0200 Subject: [PATCH 10/50] [Frontend] Rm suspense for SSR --- frontend/src/app/products/[id]/[[...productInfo]]/layout.tsx | 5 ----- frontend/src/app/products/page.tsx | 4 +--- 2 files changed, 1 insertion(+), 8 deletions(-) delete mode 100644 frontend/src/app/products/[id]/[[...productInfo]]/layout.tsx diff --git a/frontend/src/app/products/[id]/[[...productInfo]]/layout.tsx b/frontend/src/app/products/[id]/[[...productInfo]]/layout.tsx deleted file mode 100644 index 0a100f8..0000000 --- a/frontend/src/app/products/[id]/[[...productInfo]]/layout.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Suspense } from 'react' - -export default function Layout({ children }: React.PropsWithChildren<{}>) { - return Loading product...}>{children} -} diff --git a/frontend/src/app/products/page.tsx b/frontend/src/app/products/page.tsx index 7a98896..b7e7f88 100644 --- a/frontend/src/app/products/page.tsx +++ b/frontend/src/app/products/page.tsx @@ -39,9 +39,7 @@ export default function Page() { - Loading...}> - - + ) From 0393ba72f8410aa67a47c7bee604234117990717 Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Mon, 29 Apr 2024 19:28:10 +0200 Subject: [PATCH 11/50] [Frontend] Added pagination --- .../src/components/Pagination/Pagination.tsx | 46 +++++++++++++++++++ frontend/src/components/Pagination/index.ts | 1 + frontend/src/components/Pagination/utils.ts | 20 ++++++++ frontend/src/modules/Products/Products.tsx | 3 +- .../components/ProductList/ProductList.tsx | 45 ++++++++++++++++-- frontend/src/modules/Products/const.ts | 2 +- frontend/src/modules/Products/context.tsx | 11 +++-- .../src/modules/Products/reducer/actions.ts | 9 +++- .../src/modules/Products/reducer/interface.ts | 4 +- .../src/modules/Products/reducer/reducer.ts | 10 ++++ frontend/src/utils/api/utils.ts | 2 + 11 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/Pagination/Pagination.tsx create mode 100644 frontend/src/components/Pagination/index.ts create mode 100644 frontend/src/components/Pagination/utils.ts diff --git a/frontend/src/components/Pagination/Pagination.tsx b/frontend/src/components/Pagination/Pagination.tsx new file mode 100644 index 0000000..730bc8e --- /dev/null +++ b/frontend/src/components/Pagination/Pagination.tsx @@ -0,0 +1,46 @@ +import { ChevronLeft, ChevronRight } from '@carbon/icons-react' +import { Button, Flex, FlexProps } from '@chakra-ui/react' +import { getPages } from './utils' + +interface Props extends FlexProps { + page: number + totalPages: number + onPageChange: (page: number) => void +} + +export default function Pagination({ onPageChange, page, totalPages, ...flexProps }: Props) { + const goToFirstPage = () => onPageChange(1) + const goToPreviousPage = () => onPageChange(page - 1) + const goToNextPage = () => onPageChange(page + 1) + const goToLastPage = () => onPageChange(totalPages) + + const pages = getPages(page, totalPages) + + return ( + + + + + + {pages.map(p => ( + + ))} + + + + + + ) +} diff --git a/frontend/src/components/Pagination/index.ts b/frontend/src/components/Pagination/index.ts new file mode 100644 index 0000000..40ac52f --- /dev/null +++ b/frontend/src/components/Pagination/index.ts @@ -0,0 +1 @@ +export { default } from './Pagination' diff --git a/frontend/src/components/Pagination/utils.ts b/frontend/src/components/Pagination/utils.ts new file mode 100644 index 0000000..10a7565 --- /dev/null +++ b/frontend/src/components/Pagination/utils.ts @@ -0,0 +1,20 @@ +// Must be odd number to keep current page in the middle +const NUMBER_OF_PAGES_SHOWN = 5 + +export const getPages = (page: number, totalPages: number) => { + const aroundCurrentPage = (NUMBER_OF_PAGES_SHOWN - 1) / 2 + + if (totalPages <= NUMBER_OF_PAGES_SHOWN) { + return Array.from({ length: totalPages }, (_, i) => i + 1) + } + + if (page <= aroundCurrentPage) { + return Array.from({ length: NUMBER_OF_PAGES_SHOWN }, (_, i) => i + 1) + } + + if (page >= totalPages - aroundCurrentPage) { + return Array.from({ length: NUMBER_OF_PAGES_SHOWN }, (_, i) => totalPages - NUMBER_OF_PAGES_SHOWN + i + 1) + } + + return Array.from({ length: NUMBER_OF_PAGES_SHOWN }, (_, i) => page - aroundCurrentPage + i) +} diff --git a/frontend/src/modules/Products/Products.tsx b/frontend/src/modules/Products/Products.tsx index 807be45..55494f0 100644 --- a/frontend/src/modules/Products/Products.tsx +++ b/frontend/src/modules/Products/Products.tsx @@ -8,11 +8,12 @@ export default async function Products() { products = await getProducts({}) } catch (e) { console.error(e) + return null } return ( <> - + ) } diff --git a/frontend/src/modules/Products/components/ProductList/ProductList.tsx b/frontend/src/modules/Products/components/ProductList/ProductList.tsx index 1684043..f13fed7 100644 --- a/frontend/src/modules/Products/components/ProductList/ProductList.tsx +++ b/frontend/src/modules/Products/components/ProductList/ProductList.tsx @@ -1,22 +1,59 @@ 'use client' import { Flex } from '@chakra-ui/react' +import { getProducts } from 'api/Product/repository' +import Pagination from 'components/Pagination' import { ProductSlot } from 'components/Product' import { Product } from 'model/Product' -import { ProductsProvider } from 'modules/Products/context' +import { ProductsProvider, useProductsDispatch, useProductsState } from 'modules/Products/context' +import { setCurrentPage, setProducts } from 'modules/Products/reducer/actions' +import { useEffect, useRef } from 'react' interface Props { products: Product[] + totalPages: number } -export default function ProductList({ products }: Props) { +function ProductList({}: Props) { + const isFirstLoad = useRef(true) + + const { products, currentPage, totalPages, size, sort, filters } = useProductsState() + const dispatch = useProductsDispatch() + + useEffect(() => { + if (isFirstLoad.current) { + isFirstLoad.current = false + return + } + + getProducts({ page: currentPage - 1, size, sort, min: filters.min, max: filters.max }) + .then(({ products, totalPages }) => dispatch(setProducts(products, totalPages))) + .catch(console.error) + }, [currentPage, size, sort, filters]) + + const handlePageChange = (page: number) => dispatch(setCurrentPage(page)) + return ( - + + + {products.map(product => ( ))} - + + + ) } + +const withProvider = (Component: React.FC) => (props: Props) => ( + + + +) + +const ProductListWithProvider = withProvider(ProductList) + +export default ProductListWithProvider diff --git a/frontend/src/modules/Products/const.ts b/frontend/src/modules/Products/const.ts index d98cb13..9730997 100644 --- a/frontend/src/modules/Products/const.ts +++ b/frontend/src/modules/Products/const.ts @@ -1,6 +1,6 @@ import { IProductsState } from './interface' -export const PRODUCT_PAGE_SIZES = [5, 10, 20, 50] +export const PRODUCT_PAGE_SIZES = [5, 12, 20, 50] const DEFAULT_PRODUCT_PAGE_SIZE = PRODUCT_PAGE_SIZES[1] diff --git a/frontend/src/modules/Products/context.tsx b/frontend/src/modules/Products/context.tsx index 309ed4b..a2e0a1f 100644 --- a/frontend/src/modules/Products/context.tsx +++ b/frontend/src/modules/Products/context.tsx @@ -1,8 +1,9 @@ -import React, { createContext, useReducer } from 'react' +import React, { createContext, useEffect, useReducer } from 'react' import { reducer } from './reducer/reducer' import { IProductsState } from './interface' import { ProductsActions } from './reducer/interface' import { Product } from 'model/Product' +import { INITIAL_PRODUCTS_STATE } from './const' interface IProductsContext { state: IProductsState @@ -11,8 +12,12 @@ interface IProductsContext { const ProductsContext = createContext({} as IProductsContext) -export const ProductsProvider = ({ children, products }: React.PropsWithChildren<{ products: Product[] }>) => { - const [state, dispatch] = useReducer(reducer, { products } as IProductsState) +export const ProductsProvider = ({ + children, + products, + totalPages, +}: React.PropsWithChildren<{ products: Product[]; totalPages: number }>) => { + const [state, dispatch] = useReducer(reducer, { ...INITIAL_PRODUCTS_STATE, products, totalPages } as IProductsState) return {children} } diff --git a/frontend/src/modules/Products/reducer/actions.ts b/frontend/src/modules/Products/reducer/actions.ts index eb00ba7..21c6c8e 100644 --- a/frontend/src/modules/Products/reducer/actions.ts +++ b/frontend/src/modules/Products/reducer/actions.ts @@ -1,5 +1,5 @@ import { Product } from 'model/Product' -import { SetProductsAction } from './interface' +import { SetCurrentPageAction, SetProductsAction } from './interface' /** Products */ @@ -7,3 +7,10 @@ export const setProducts = (products: Product[], totalPages: number): SetProduct type: 'SET_PRODUCTS', payload: { products, totalPages }, }) + +/** Pagination */ + +export const setCurrentPage = (currentPage: number): SetCurrentPageAction => ({ + type: 'SET_CURRENT_PAGE', + payload: currentPage, +}) diff --git a/frontend/src/modules/Products/reducer/interface.ts b/frontend/src/modules/Products/reducer/interface.ts index 0f68644..0c24ffb 100644 --- a/frontend/src/modules/Products/reducer/interface.ts +++ b/frontend/src/modules/Products/reducer/interface.ts @@ -9,4 +9,6 @@ export type SetProductsAction = Action< } > -export type ProductsActions = SetProductsAction +export type SetCurrentPageAction = Action<'SET_CURRENT_PAGE', number> + +export type ProductsActions = SetProductsAction | SetCurrentPageAction diff --git a/frontend/src/modules/Products/reducer/reducer.ts b/frontend/src/modules/Products/reducer/reducer.ts index c8d4d16..90ddb5e 100644 --- a/frontend/src/modules/Products/reducer/reducer.ts +++ b/frontend/src/modules/Products/reducer/reducer.ts @@ -2,6 +2,7 @@ import { IProductsState } from '../interface' import { ProductsActions } from './interface' export const SET_PRODUCTS = 'SET_PRODUCTS' +export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE' export const reducer = (state: IProductsState, action: ProductsActions): IProductsState => { switch (action.type) { @@ -15,6 +16,15 @@ export const reducer = (state: IProductsState, action: ProductsActions): IProduc } } + case SET_CURRENT_PAGE: { + const currentPage = action.payload + + return { + ...state, + currentPage, + } + } + default: return state } diff --git a/frontend/src/utils/api/utils.ts b/frontend/src/utils/api/utils.ts index 66493d6..8bf22ac 100644 --- a/frontend/src/utils/api/utils.ts +++ b/frontend/src/utils/api/utils.ts @@ -75,5 +75,7 @@ export async function postJson(url: string, body?: any, options export const query = (params: Record) => { if (Object.keys(params).length === 0) return '' + Object.keys(params).forEach(key => params[key] === undefined && delete params[key]) + return `?${new URLSearchParams(params).toString()}` } From 3bdf619288841bb349572007cd2582d1c462e297 Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Mon, 29 Apr 2024 20:11:10 +0200 Subject: [PATCH 12/50] [Frontend] Filter by price --- frontend/src/app/products/page.tsx | 4 +- .../components/ProductList/ProductList.tsx | 53 ++++++-- .../components/Filters/Filters.tsx | 116 ++++++++++++++++++ .../ProductList/components/Filters/index.ts | 1 + .../ProductList/components/index.ts | 1 + .../src/modules/Products/reducer/actions.ts | 10 +- .../src/modules/Products/reducer/interface.ts | 5 +- .../src/modules/Products/reducer/reducer.ts | 10 ++ 8 files changed, 185 insertions(+), 15 deletions(-) create mode 100644 frontend/src/modules/Products/components/ProductList/components/Filters/Filters.tsx create mode 100644 frontend/src/modules/Products/components/ProductList/components/Filters/index.ts create mode 100644 frontend/src/modules/Products/components/ProductList/components/index.ts diff --git a/frontend/src/app/products/page.tsx b/frontend/src/app/products/page.tsx index b7e7f88..8c6aacc 100644 --- a/frontend/src/app/products/page.tsx +++ b/frontend/src/app/products/page.tsx @@ -38,9 +38,7 @@ export default function Page() { - - - + ) } diff --git a/frontend/src/modules/Products/components/ProductList/ProductList.tsx b/frontend/src/modules/Products/components/ProductList/ProductList.tsx index f13fed7..31370ba 100644 --- a/frontend/src/modules/Products/components/ProductList/ProductList.tsx +++ b/frontend/src/modules/Products/components/ProductList/ProductList.tsx @@ -1,6 +1,17 @@ 'use client' -import { Flex } from '@chakra-ui/react' +import { ChevronRight, Filter } from '@carbon/icons-react' +import { + Box, + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + Button, + Container, + Flex, + useColorModeValue, + useDisclosure, +} from '@chakra-ui/react' import { getProducts } from 'api/Product/repository' import Pagination from 'components/Pagination' import { ProductSlot } from 'components/Product' @@ -8,6 +19,7 @@ import { Product } from 'model/Product' import { ProductsProvider, useProductsDispatch, useProductsState } from 'modules/Products/context' import { setCurrentPage, setProducts } from 'modules/Products/reducer/actions' import { useEffect, useRef } from 'react' +import { Filters } from './components' interface Props { products: Product[] @@ -20,6 +32,10 @@ function ProductList({}: Props) { const { products, currentPage, totalPages, size, sort, filters } = useProductsState() const dispatch = useProductsDispatch() + const { isOpen: areFiltersOpen, onOpen: openFilters, onClose: onFiltersClose } = useDisclosure() + + const breadcrumbBarBg = useColorModeValue('yellow.200', 'orange.700') + useEffect(() => { if (isFirstLoad.current) { isFirstLoad.current = false @@ -34,17 +50,34 @@ function ProductList({}: Props) { const handlePageChange = (page: number) => dispatch(setCurrentPage(page)) return ( - - - - - {products.map(product => ( - - ))} + <> + + + + - - + + + + + + + + {products.map(product => ( + + ))} + + + + + + ) } diff --git a/frontend/src/modules/Products/components/ProductList/components/Filters/Filters.tsx b/frontend/src/modules/Products/components/ProductList/components/Filters/Filters.tsx new file mode 100644 index 0000000..f281a55 --- /dev/null +++ b/frontend/src/modules/Products/components/ProductList/components/Filters/Filters.tsx @@ -0,0 +1,116 @@ +import { CurrencyEuro } from '@carbon/icons-react' +import { + Button, + Flex, + FormControl, + FormErrorMessage, + Input, + InputGroup, + InputLeftElement, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Text, +} from '@chakra-ui/react' +import { useProductsDispatch, useProductsState } from 'modules/Products/context' +import { setFilters } from 'modules/Products/reducer/actions' +import { useEffect, useState } from 'react' + +interface Props { + isOpen: boolean + onClose: () => void +} + +export default function Filters({ isOpen, onClose }: Props) { + const { filters } = useProductsState() + + const [minError, setMinError] = useState(false) + + const [min, setMin] = useState(filters.min) + const [max, setMax] = useState(filters.max) + + const dispatch = useProductsDispatch() + + const handleMinChange = (e: React.ChangeEvent) => + setMin(e.target.value ? Number(e.target.value) : undefined) + const handleMaxChange = (e: React.ChangeEvent) => + setMax(e.target.value ? Number(e.target.value) : undefined) + + const handleSubmit = () => { + dispatch(setFilters({ min, max })) + onClose() + } + + useEffect(() => { + setMin(filters.min) + setMax(filters.max) + }, [isOpen]) + + useEffect(() => { + if (min === undefined || max === undefined) { + setMinError(false) + return + } + + setMinError(Number(min) > Number(max)) + }, [min, max]) + + return ( + <> + + + + + Product filters + + + + + + Specify product search filters: + + + Price range: + + + + + + + + + + + {minError && Minimum price must be lower than maximum price} + + + - + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/frontend/src/modules/Products/components/ProductList/components/Filters/index.ts b/frontend/src/modules/Products/components/ProductList/components/Filters/index.ts new file mode 100644 index 0000000..a51d7e5 --- /dev/null +++ b/frontend/src/modules/Products/components/ProductList/components/Filters/index.ts @@ -0,0 +1 @@ +export { default } from './Filters' diff --git a/frontend/src/modules/Products/components/ProductList/components/index.ts b/frontend/src/modules/Products/components/ProductList/components/index.ts new file mode 100644 index 0000000..b8da57a --- /dev/null +++ b/frontend/src/modules/Products/components/ProductList/components/index.ts @@ -0,0 +1 @@ +export { default as Filters } from './Filters' diff --git a/frontend/src/modules/Products/reducer/actions.ts b/frontend/src/modules/Products/reducer/actions.ts index 21c6c8e..4f422cc 100644 --- a/frontend/src/modules/Products/reducer/actions.ts +++ b/frontend/src/modules/Products/reducer/actions.ts @@ -1,5 +1,6 @@ import { Product } from 'model/Product' -import { SetCurrentPageAction, SetProductsAction } from './interface' +import { SetCurrentPageAction, SetFiltersAction, SetProductsAction } from './interface' +import { IProductsState } from '../interface' /** Products */ @@ -14,3 +15,10 @@ export const setCurrentPage = (currentPage: number): SetCurrentPageAction => ({ type: 'SET_CURRENT_PAGE', payload: currentPage, }) + +/** Filters */ + +export const setFilters = (filters: IProductsState['filters']): SetFiltersAction => ({ + type: 'SET_FILTERS', + payload: filters, +}) diff --git a/frontend/src/modules/Products/reducer/interface.ts b/frontend/src/modules/Products/reducer/interface.ts index 0c24ffb..75abc7a 100644 --- a/frontend/src/modules/Products/reducer/interface.ts +++ b/frontend/src/modules/Products/reducer/interface.ts @@ -1,5 +1,6 @@ import { Product } from 'model/Product' import { Action } from 'utils/interface' +import { IProductsState } from '../interface' export type SetProductsAction = Action< 'SET_PRODUCTS', @@ -11,4 +12,6 @@ export type SetProductsAction = Action< export type SetCurrentPageAction = Action<'SET_CURRENT_PAGE', number> -export type ProductsActions = SetProductsAction | SetCurrentPageAction +export type SetFiltersAction = Action<'SET_FILTERS', IProductsState['filters']> + +export type ProductsActions = SetProductsAction | SetCurrentPageAction | SetFiltersAction diff --git a/frontend/src/modules/Products/reducer/reducer.ts b/frontend/src/modules/Products/reducer/reducer.ts index 90ddb5e..2d5a7dc 100644 --- a/frontend/src/modules/Products/reducer/reducer.ts +++ b/frontend/src/modules/Products/reducer/reducer.ts @@ -3,6 +3,7 @@ import { ProductsActions } from './interface' export const SET_PRODUCTS = 'SET_PRODUCTS' export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE' +export const SET_FILTERS = 'SET_FILTERS' export const reducer = (state: IProductsState, action: ProductsActions): IProductsState => { switch (action.type) { @@ -25,6 +26,15 @@ export const reducer = (state: IProductsState, action: ProductsActions): IProduc } } + case SET_FILTERS: { + const filters = action.payload + + return { + ...state, + filters, + } + } + default: return state } From 1dc82b65c626dba1ec470ba8037ef33f6a048115 Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Sat, 4 May 2024 19:01:48 +0200 Subject: [PATCH 13/50] [Backend] Create product API --- .../attribute/dto/AttributeValueDTO.java | 15 +++++ .../attribute/model/AttributeValue.java | 12 ++++ .../repository/AttributeRepository.java | 11 ++++ .../repository/AttributeValueRepository.java | 13 ++++ .../attribute/service/AttributeService.java | 19 ++++++ .../service/AttributeValueService.java | 30 ++++++++++ .../product/controller/ProductController.java | 9 +-- .../backend/product/dto/CreateProductDTO.java | 23 +++++++ .../product/service/ProductService.java | 60 ++++++++++++++++++- .../backend/security/UserPrincipal.java | 2 + 10 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 backend/src/main/java/net/pinehaus/backend/attribute/dto/AttributeValueDTO.java create mode 100644 backend/src/main/java/net/pinehaus/backend/attribute/repository/AttributeRepository.java create mode 100644 backend/src/main/java/net/pinehaus/backend/attribute/repository/AttributeValueRepository.java create mode 100644 backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeService.java create mode 100644 backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeValueService.java create mode 100644 backend/src/main/java/net/pinehaus/backend/product/dto/CreateProductDTO.java diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/dto/AttributeValueDTO.java b/backend/src/main/java/net/pinehaus/backend/attribute/dto/AttributeValueDTO.java new file mode 100644 index 0000000..96ba212 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/dto/AttributeValueDTO.java @@ -0,0 +1,15 @@ +package net.pinehaus.backend.attribute.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@RequiredArgsConstructor +public class AttributeValueDTO { + + private int attributeId; + private String value; + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValue.java b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValue.java index 72b56fd..d803836 100644 --- a/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValue.java +++ b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValue.java @@ -4,8 +4,10 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; +import jakarta.persistence.IdClass; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import java.io.Serializable; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.Setter; @@ -15,6 +17,7 @@ @Getter @Setter @RequiredArgsConstructor +@IdClass(AttributeValue.AttributeValueId.class) public class AttributeValue { @Id @@ -31,4 +34,13 @@ public class AttributeValue { @Column private String value; + @Getter + @Setter + @RequiredArgsConstructor + public static class AttributeValueId implements Serializable { + + private int attribute; + private int product; + } + } \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/repository/AttributeRepository.java b/backend/src/main/java/net/pinehaus/backend/attribute/repository/AttributeRepository.java new file mode 100644 index 0000000..ef78659 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/repository/AttributeRepository.java @@ -0,0 +1,11 @@ +package net.pinehaus.backend.attribute.repository; + +import java.util.Optional; +import net.pinehaus.backend.attribute.model.Attribute; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AttributeRepository extends JpaRepository { + + Optional findById(int id); + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/repository/AttributeValueRepository.java b/backend/src/main/java/net/pinehaus/backend/attribute/repository/AttributeValueRepository.java new file mode 100644 index 0000000..ccb935f --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/repository/AttributeValueRepository.java @@ -0,0 +1,13 @@ +package net.pinehaus.backend.attribute.repository; + +import net.pinehaus.backend.attribute.model.Attribute; +import net.pinehaus.backend.attribute.model.AttributeValue; +import net.pinehaus.backend.product.model.Product; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AttributeValueRepository extends + JpaRepository { + + void deleteByAttributeAndProduct(Attribute attribute, Product product); + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeService.java b/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeService.java new file mode 100644 index 0000000..1e9aa67 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeService.java @@ -0,0 +1,19 @@ +package net.pinehaus.backend.attribute.service; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import net.pinehaus.backend.attribute.model.Attribute; +import net.pinehaus.backend.attribute.repository.AttributeRepository; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AttributeService { + + private final AttributeRepository attributeRepository; + + public Optional getById(int id) { + return attributeRepository.findById(id); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeValueService.java b/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeValueService.java new file mode 100644 index 0000000..15e943e --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeValueService.java @@ -0,0 +1,30 @@ +package net.pinehaus.backend.attribute.service; + +import lombok.RequiredArgsConstructor; +import net.pinehaus.backend.attribute.model.Attribute; +import net.pinehaus.backend.attribute.model.AttributeValue; +import net.pinehaus.backend.attribute.repository.AttributeValueRepository; +import net.pinehaus.backend.product.model.Product; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AttributeValueService { + + private final AttributeValueRepository attributeValueRepository; + + public AttributeValue setProductAttribute(Product product, Attribute attribute, String value) { + AttributeValue attributeValue = new AttributeValue(); + + attributeValue.setProduct(product); + attributeValue.setAttribute(attribute); + attributeValue.setValue(value); + + return attributeValueRepository.save(attributeValue); + } + + public void removeProductAttribute(Product product, Attribute attribute) { + attributeValueRepository.deleteByAttributeAndProduct(attribute, product); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java b/backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java index 352c619..3f75d53 100644 --- a/backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java +++ b/backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import java.util.Optional; import lombok.RequiredArgsConstructor; +import net.pinehaus.backend.product.dto.CreateProductDTO; import net.pinehaus.backend.product.dto.ProductPageResponse; import net.pinehaus.backend.product.model.Product; import net.pinehaus.backend.product.service.ProductService; @@ -83,13 +84,13 @@ public Product getProduct(@PathVariable int id) { @PreAuthorize("hasAuthority('USER')") @Operation(summary = "Create a product.", description = "Create a new product.") @ApiResponses({@ApiResponse(responseCode = "200"), @ApiResponse(responseCode = "409")}) - public Product createProduct(Product product) { - if (productService.existsBySku(product.getSku()) || productService.existsBySlug( - product.getSlug())) { + public Product createProduct(@RequestBody CreateProductDTO product, + @AuthenticationPrincipal UserPrincipal currentUser) { + if (productService.existsBySku(product.getSku())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Product already exists"); } - return productService.createProduct(product); + return productService.createProduct(product, currentUser.getUser()); } @PutMapping("/{id}") diff --git a/backend/src/main/java/net/pinehaus/backend/product/dto/CreateProductDTO.java b/backend/src/main/java/net/pinehaus/backend/product/dto/CreateProductDTO.java new file mode 100644 index 0000000..8c64b19 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/product/dto/CreateProductDTO.java @@ -0,0 +1,23 @@ +package net.pinehaus.backend.product.dto; + +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import net.pinehaus.backend.attribute.dto.AttributeValueDTO; + +@Getter +@Setter +@RequiredArgsConstructor +public class CreateProductDTO { + + private String name; + private String description; + private String sku; + private int quantity; + private double price; + private String thumbnail; + private int categoryId; + private List attributes; + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java b/backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java index 2b20685..0387ea6 100644 --- a/backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java +++ b/backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java @@ -1,9 +1,20 @@ package net.pinehaus.backend.product.service; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; +import net.pinehaus.backend.attribute.dto.AttributeValueDTO; +import net.pinehaus.backend.attribute.model.Attribute; +import net.pinehaus.backend.attribute.service.AttributeService; +import net.pinehaus.backend.attribute.service.AttributeValueService; +import net.pinehaus.backend.category.model.Category; +import net.pinehaus.backend.category.service.CategoryService; +import net.pinehaus.backend.product.dto.CreateProductDTO; import net.pinehaus.backend.product.model.Product; import net.pinehaus.backend.product.repository.ProductRepository; +import net.pinehaus.backend.user.model.UserEntity; +import net.pinehaus.backend.util.Slugify; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -14,6 +25,9 @@ public class ProductService { private final ProductRepository productRepository; + private final AttributeService attributeService; + private final AttributeValueService attributeValueService; + private final CategoryService categoryService; public Optional getProductBySku(String sku) { return productRepository.findBySku(sku); @@ -50,8 +64,50 @@ public Optional getProductById(int id) { return productRepository.findById(id); } - public Product createProduct(Product product) { - return productRepository.save(product); + public Product createProduct(CreateProductDTO product, UserEntity user) { + Product newProduct = new Product(); + + newProduct.setName(product.getName()); + newProduct.setDescription(product.getDescription()); + newProduct.setSku(product.getSku()); + newProduct.setQuantity(product.getQuantity()); + newProduct.setPrice(product.getPrice()); + newProduct.setThumbnail(product.getThumbnail()); + newProduct.setCreatedBy(user); + newProduct.setAttributes(new ArrayList<>()); + newProduct.setSlug(Slugify.slugify(product.getName())); + + /* Set attributes */ + List attributes = product.getAttributes(); + + newProduct = productRepository.save(newProduct); + + for (AttributeValueDTO attribute : attributes) { + Optional attributeOptional = attributeService.getById(attribute.getAttributeId()); + + if (attributeOptional.isEmpty()) { + throw new IllegalArgumentException( + "Attribute with id " + attribute.getAttributeId() + " does not exist."); + } + + newProduct.getAttributes().add(attributeValueService.setProductAttribute( + newProduct, + attributeOptional.get(), + attribute.getValue() + )); + } + + /* Set category */ + Optional category = categoryService.getCategoryById(product.getCategoryId()); + + if (category.isEmpty()) { + throw new IllegalArgumentException( + "Category with id " + product.getCategoryId() + " does not exist."); + } + + newProduct.setCategory(category.get()); + + return newProduct; } public boolean existsBySku(String sku) { diff --git a/backend/src/main/java/net/pinehaus/backend/security/UserPrincipal.java b/backend/src/main/java/net/pinehaus/backend/security/UserPrincipal.java index f65a917..5779854 100644 --- a/backend/src/main/java/net/pinehaus/backend/security/UserPrincipal.java +++ b/backend/src/main/java/net/pinehaus/backend/security/UserPrincipal.java @@ -17,6 +17,7 @@ @AllArgsConstructor public class UserPrincipal implements UserDetails { + private UserEntity user; private UUID id; private String firstName; private String lastName; @@ -32,6 +33,7 @@ public class UserPrincipal implements UserDetails { public UserPrincipal(UserEntity user) { List authorities = AuthorityUtils.createAuthorityList(ADMIN, USER); + this.user = user; this.id = user.getId(); this.firstName = user.getFirstName(); this.lastName = user.getLastName(); From efa3fce41752ef08f8a6c0582302fca453b52e58 Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Sat, 4 May 2024 19:02:20 +0200 Subject: [PATCH 14/50] [Backend] Slugify --- .../net/pinehaus/backend/util/Slugify.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 backend/src/main/java/net/pinehaus/backend/util/Slugify.java diff --git a/backend/src/main/java/net/pinehaus/backend/util/Slugify.java b/backend/src/main/java/net/pinehaus/backend/util/Slugify.java new file mode 100644 index 0000000..c710630 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/util/Slugify.java @@ -0,0 +1,19 @@ +package net.pinehaus.backend.util; + +import java.text.Normalizer; +import java.text.Normalizer.Form; +import java.util.Locale; +import java.util.regex.Pattern; + +public class Slugify { + + private static final Pattern NON_LATIN = Pattern.compile("[^\\w-]"); + private static final Pattern WHITESPACE = Pattern.compile("[\\s]"); + + public static String slugify(String input) { + String noWhitespace = WHITESPACE.matcher(input).replaceAll("-"); + String normalized = Normalizer.normalize(noWhitespace, Form.NFD); + String slug = NON_LATIN.matcher(normalized).replaceAll(""); + return slug.toLowerCase(Locale.ENGLISH); + } +} \ No newline at end of file From 3d598cd5d6b8705963c52232185d8f5907d56945 Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Wed, 8 May 2024 10:45:10 +0200 Subject: [PATCH 15/50] [Backend] Attribute controller, correctly implemented views --- .../controller/AttributeController.java | 25 +++++++++ .../backend/attribute/model/Attribute.java | 23 +++++++- .../attribute/model/AttributeValue.java | 5 +- .../attribute/model/AttributeValueViews.java | 9 ++++ .../attribute/model/AttributeViews.java | 9 ++++ .../repository/AttributeValueRepository.java | 8 +++ .../attribute/service/AttributeService.java | 7 ++- .../service/AttributeValueService.java | 53 +++++++++++++++++++ .../backend/category/model/Category.java | 2 + .../backend/category/model/CategoryViews.java | 9 ++++ .../product/controller/ProductController.java | 13 +++-- .../product/dto/ProductPageResponse.java | 3 ++ .../backend/product/dto/UpdateProductDTO.java | 23 ++++++++ .../backend/product/model/Product.java | 7 +-- .../backend/product/model/ProductViews.java | 14 +++++ .../product/service/ProductService.java | 34 +++++++++--- .../backend/user/model/UserEntity.java | 8 +-- 17 files changed, 229 insertions(+), 23 deletions(-) create mode 100644 backend/src/main/java/net/pinehaus/backend/attribute/controller/AttributeController.java create mode 100644 backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValueViews.java create mode 100644 backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeViews.java create mode 100644 backend/src/main/java/net/pinehaus/backend/category/model/CategoryViews.java create mode 100644 backend/src/main/java/net/pinehaus/backend/product/dto/UpdateProductDTO.java create mode 100644 backend/src/main/java/net/pinehaus/backend/product/model/ProductViews.java diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/controller/AttributeController.java b/backend/src/main/java/net/pinehaus/backend/attribute/controller/AttributeController.java new file mode 100644 index 0000000..879cf9d --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/controller/AttributeController.java @@ -0,0 +1,25 @@ +package net.pinehaus.backend.attribute.controller; + +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import net.pinehaus.backend.attribute.model.Attribute; +import net.pinehaus.backend.attribute.service.AttributeService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/attributes") +@Tag(name = "Product attributes") +@RequiredArgsConstructor +public class AttributeController { + + private final AttributeService attributeService; + + @GetMapping + public List getAllAttributes() { + return attributeService.getAllAttributes(); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/model/Attribute.java b/backend/src/main/java/net/pinehaus/backend/attribute/model/Attribute.java index 98ec6c2..9100ceb 100644 --- a/backend/src/main/java/net/pinehaus/backend/attribute/model/Attribute.java +++ b/backend/src/main/java/net/pinehaus/backend/attribute/model/Attribute.java @@ -1,27 +1,34 @@ package net.pinehaus.backend.attribute.model; +import com.fasterxml.jackson.annotation.JsonView; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import java.util.List; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.Setter; @Entity -@Getter -@Setter @RequiredArgsConstructor +@JsonView(AttributeViews.Public.class) public class Attribute { @Id + @Getter + @Setter @GeneratedValue(strategy = GenerationType.IDENTITY) private int id; + @Setter + @Getter @Column(nullable = false) private String name; + @Setter + @Getter @Column(nullable = false) private AttributeType type; @@ -32,4 +39,16 @@ public class Attribute { @Column private String options; + public List getOptions() { + if (options == null) { + return null; + } + + return List.of(options.split(",")); + } + + public void setOptions(List options) { + this.options = String.join(",", options); + } + } \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValue.java b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValue.java index d803836..154b4fc 100644 --- a/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValue.java +++ b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValue.java @@ -1,6 +1,6 @@ package net.pinehaus.backend.attribute.model; -import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonView; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; @@ -23,15 +23,16 @@ public class AttributeValue { @Id @ManyToOne @JoinColumn + @JsonView(AttributeValueViews.Public.class) private Attribute attribute; @Id @ManyToOne @JoinColumn - @JsonIgnore private Product product; @Column + @JsonView(AttributeValueViews.Public.class) private String value; @Getter diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValueViews.java b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValueViews.java new file mode 100644 index 0000000..8228da2 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValueViews.java @@ -0,0 +1,9 @@ +package net.pinehaus.backend.attribute.model; + +public class AttributeValueViews { + + public interface Public extends AttributeViews.Public { + + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeViews.java b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeViews.java new file mode 100644 index 0000000..25b4b81 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeViews.java @@ -0,0 +1,9 @@ +package net.pinehaus.backend.attribute.model; + +public class AttributeViews { + + public interface Public { + + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/repository/AttributeValueRepository.java b/backend/src/main/java/net/pinehaus/backend/attribute/repository/AttributeValueRepository.java index ccb935f..71f0ea8 100644 --- a/backend/src/main/java/net/pinehaus/backend/attribute/repository/AttributeValueRepository.java +++ b/backend/src/main/java/net/pinehaus/backend/attribute/repository/AttributeValueRepository.java @@ -1,5 +1,7 @@ package net.pinehaus.backend.attribute.repository; +import java.util.List; +import java.util.Optional; import net.pinehaus.backend.attribute.model.Attribute; import net.pinehaus.backend.attribute.model.AttributeValue; import net.pinehaus.backend.product.model.Product; @@ -10,4 +12,10 @@ public interface AttributeValueRepository extends void deleteByAttributeAndProduct(Attribute attribute, Product product); + void deleteByAttributeIdAndProductId(int attributeId, int productId); + + Optional findByAttributeIdAndProductId(int attributeId, int productId); + + List findByProductId(int productId); + } \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeService.java b/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeService.java index 1e9aa67..b230348 100644 --- a/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeService.java +++ b/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeService.java @@ -1,5 +1,6 @@ package net.pinehaus.backend.attribute.service; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; import net.pinehaus.backend.attribute.model.Attribute; @@ -12,8 +13,12 @@ public class AttributeService { private final AttributeRepository attributeRepository; + public List getAllAttributes() { + return attributeRepository.findAll(); + } + public Optional getById(int id) { return attributeRepository.findById(id); } - + } \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeValueService.java b/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeValueService.java index 15e943e..d2de73a 100644 --- a/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeValueService.java +++ b/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeValueService.java @@ -1,17 +1,22 @@ package net.pinehaus.backend.attribute.service; +import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; +import net.pinehaus.backend.attribute.dto.AttributeValueDTO; import net.pinehaus.backend.attribute.model.Attribute; import net.pinehaus.backend.attribute.model.AttributeValue; import net.pinehaus.backend.attribute.repository.AttributeValueRepository; import net.pinehaus.backend.product.model.Product; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class AttributeValueService { private final AttributeValueRepository attributeValueRepository; + private final AttributeService attributeService; public AttributeValue setProductAttribute(Product product, Attribute attribute, String value) { AttributeValue attributeValue = new AttributeValue(); @@ -27,4 +32,52 @@ public void removeProductAttribute(Product product, Attribute attribute) { attributeValueRepository.deleteByAttributeAndProduct(attribute, product); } + @Transactional + public void compareAndUpdateAttributeValues(Product product, List updated) { + List current = attributeValueRepository.findByProductId(product.getId()); + + for (AttributeValue attributeValue : current) { + Optional matchingUpdatedAttribute = updated.stream() + .filter(updateAttribute -> + updateAttribute.getAttributeId() + == attributeValue.getAttribute() + .getId()) + .findFirst(); + + if (matchingUpdatedAttribute.isPresent()) { + // If value is different then update value + if (!matchingUpdatedAttribute.get().getValue().equals(attributeValue.getValue())) { + attributeValue.setValue(matchingUpdatedAttribute.get().getValue()); + attributeValueRepository.save(attributeValue); + } + } else { + // If attributeValueDTO is not found in updated, then remove it + attributeValueRepository.deleteByAttributeIdAndProductId( + attributeValue.getAttribute().getId(), product.getId()); + } + } + + for (AttributeValueDTO updatedAttribute : updated) { + Optional matchingCurrentAttribute = current.stream() + .filter(currentAttribute -> + currentAttribute.getAttribute() + .getId() + == updatedAttribute.getAttributeId()) + .findFirst(); + + if (matchingCurrentAttribute.isEmpty()) { + // If updatedAttribute is not found in current, then create it + Attribute attribute = attributeService.getById(updatedAttribute.getAttributeId()) + .orElseThrow(); + + AttributeValue newAttributeValue = new AttributeValue(); + newAttributeValue.setProduct(product); + newAttributeValue.setAttribute(attribute); + newAttributeValue.setValue(updatedAttribute.getValue()); + attributeValueRepository.save(newAttributeValue); + } + } + + } + } \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/category/model/Category.java b/backend/src/main/java/net/pinehaus/backend/category/model/Category.java index 2ce9228..e57bbd3 100644 --- a/backend/src/main/java/net/pinehaus/backend/category/model/Category.java +++ b/backend/src/main/java/net/pinehaus/backend/category/model/Category.java @@ -1,5 +1,6 @@ package net.pinehaus.backend.category.model; +import com.fasterxml.jackson.annotation.JsonView; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -14,6 +15,7 @@ @Getter @Setter @RequiredArgsConstructor +@JsonView(CategoryViews.Public.class) public class Category { @Id diff --git a/backend/src/main/java/net/pinehaus/backend/category/model/CategoryViews.java b/backend/src/main/java/net/pinehaus/backend/category/model/CategoryViews.java new file mode 100644 index 0000000..17795c2 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/category/model/CategoryViews.java @@ -0,0 +1,9 @@ +package net.pinehaus.backend.category.model; + +public class CategoryViews { + + public interface Public { + + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java b/backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java index 3f75d53..64e458d 100644 --- a/backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java +++ b/backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java @@ -9,10 +9,11 @@ import lombok.RequiredArgsConstructor; import net.pinehaus.backend.product.dto.CreateProductDTO; import net.pinehaus.backend.product.dto.ProductPageResponse; +import net.pinehaus.backend.product.dto.UpdateProductDTO; import net.pinehaus.backend.product.model.Product; +import net.pinehaus.backend.product.model.ProductViews; import net.pinehaus.backend.product.service.ProductService; import net.pinehaus.backend.security.UserPrincipal; -import net.pinehaus.backend.user.model.UserViews; import org.springframework.data.domain.Page; import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; @@ -43,6 +44,7 @@ public class ProductController { @Operation(summary = "Get paginated products list.", description = "Fetch paginated products list, optionally filtered by category and price range.") @ApiResponses({@ApiResponse(responseCode = "200"), @ApiResponse(responseCode = "404")}) + @JsonView(ProductViews.Public.class) public ProductPageResponse getProduct(@RequestParam(required = false) Optional page, @RequestParam(required = false) Optional size, @RequestParam(required = false) Optional sort, @@ -72,6 +74,7 @@ public ProductPageResponse getProduct(@RequestParam(required = false) Optional existingProduct = productService.getProductById(id); if (existingProduct.isEmpty()) { @@ -114,7 +119,7 @@ public Product updateProduct(@PathVariable int id, "You are not allowed to update this product"); } - return productService.updateProduct(product); + return productService.updateProduct(product, productUpdate); } diff --git a/backend/src/main/java/net/pinehaus/backend/product/dto/ProductPageResponse.java b/backend/src/main/java/net/pinehaus/backend/product/dto/ProductPageResponse.java index 7f4be15..341aaa0 100644 --- a/backend/src/main/java/net/pinehaus/backend/product/dto/ProductPageResponse.java +++ b/backend/src/main/java/net/pinehaus/backend/product/dto/ProductPageResponse.java @@ -1,13 +1,16 @@ package net.pinehaus.backend.product.dto; +import com.fasterxml.jackson.annotation.JsonView; import java.util.List; import lombok.Getter; import lombok.Setter; import net.pinehaus.backend.product.model.Product; +import net.pinehaus.backend.product.model.ProductViews; import org.springframework.data.domain.Page; @Getter @Setter +@JsonView(ProductViews.Public.class) public class ProductPageResponse { private int totalPages; diff --git a/backend/src/main/java/net/pinehaus/backend/product/dto/UpdateProductDTO.java b/backend/src/main/java/net/pinehaus/backend/product/dto/UpdateProductDTO.java new file mode 100644 index 0000000..496da70 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/product/dto/UpdateProductDTO.java @@ -0,0 +1,23 @@ +package net.pinehaus.backend.product.dto; + +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import net.pinehaus.backend.attribute.dto.AttributeValueDTO; + +@Getter +@Setter +@RequiredArgsConstructor +public class UpdateProductDTO { + + private String name; + private String description; + private String sku; + private int quantity; + private double price; + private String thumbnail; + private int categoryId; + private List attributes; + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/model/Product.java b/backend/src/main/java/net/pinehaus/backend/product/model/Product.java index fe689f5..4893e45 100644 --- a/backend/src/main/java/net/pinehaus/backend/product/model/Product.java +++ b/backend/src/main/java/net/pinehaus/backend/product/model/Product.java @@ -17,12 +17,12 @@ import net.pinehaus.backend.attribute.model.AttributeValue; import net.pinehaus.backend.category.model.Category; import net.pinehaus.backend.user.model.UserEntity; -import net.pinehaus.backend.user.model.UserViews; @Entity @Getter @Setter @RequiredArgsConstructor +@JsonView(ProductViews.Public.class) public class Product { @Id @@ -48,6 +48,7 @@ public class Product { private double price; @Column + @JsonView(ProductViews.Public.class) private String thumbnail; @OneToMany(mappedBy = "product", cascade = CascadeType.ALL) @@ -55,11 +56,11 @@ public class Product { @JoinColumn(nullable = false) @ManyToOne - @Getter(onMethod_ = {@JsonView(UserViews.Public.class)}) - @JsonView(UserViews.Public.class) + @JsonView(ProductViews.Public.class) private UserEntity createdBy; @JoinColumn @ManyToOne + @JsonView(ProductViews.Public.class) private Category category; } \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/model/ProductViews.java b/backend/src/main/java/net/pinehaus/backend/product/model/ProductViews.java new file mode 100644 index 0000000..12a6fa8 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/product/model/ProductViews.java @@ -0,0 +1,14 @@ +package net.pinehaus.backend.product.model; + + +import net.pinehaus.backend.attribute.model.AttributeValueViews; +import net.pinehaus.backend.category.model.CategoryViews; +import net.pinehaus.backend.user.model.UserViews; + +public class ProductViews { + + public interface Public extends UserViews.Public, AttributeValueViews.Public, + CategoryViews.Public { + + } +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java b/backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java index 0387ea6..d38e090 100644 --- a/backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java +++ b/backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java @@ -11,6 +11,7 @@ import net.pinehaus.backend.category.model.Category; import net.pinehaus.backend.category.service.CategoryService; import net.pinehaus.backend.product.dto.CreateProductDTO; +import net.pinehaus.backend.product.dto.UpdateProductDTO; import net.pinehaus.backend.product.model.Product; import net.pinehaus.backend.product.repository.ProductRepository; import net.pinehaus.backend.user.model.UserEntity; @@ -44,8 +45,7 @@ public Page getProductsByCategoryId(int categoryId, int page, int size, } public Page getProductsByCategoryIdAndPriceBetween(int categoryId, double min, - double max, - int page, int size, Sort.Direction sortOrder) { + double max, int page, int size, Sort.Direction sortOrder) { return productRepository.findAllByCategoryIdAndPriceBetween(categoryId, min, max, PageRequest.of(page, size, Sort.by(sortOrder, "price"))); } @@ -90,11 +90,9 @@ public Product createProduct(CreateProductDTO product, UserEntity user) { "Attribute with id " + attribute.getAttributeId() + " does not exist."); } - newProduct.getAttributes().add(attributeValueService.setProductAttribute( - newProduct, - attributeOptional.get(), - attribute.getValue() - )); + newProduct.getAttributes() + .add(attributeValueService.setProductAttribute(newProduct, attributeOptional.get(), + attribute.getValue())); } /* Set category */ @@ -122,7 +120,27 @@ public boolean existsById(int id) { return productRepository.existsById(id); } - public Product updateProduct(Product product) { + public Product updateProduct(Product product, UpdateProductDTO payload) { + product.setName(payload.getName()); + product.setDescription(payload.getDescription()); + product.setSku(payload.getSku()); + product.setQuantity(payload.getQuantity()); + product.setPrice(payload.getPrice()); + product.setThumbnail(payload.getThumbnail()); + product.setSlug(Slugify.slugify(payload.getName())); + + /* Set category */ + Optional category = categoryService.getCategoryById(payload.getCategoryId()); + + if (category.isEmpty()) { + throw new IllegalArgumentException( + "Category with id " + payload.getCategoryId() + " does not exist."); + } + + product.setCategory(category.get()); + + attributeValueService.compareAndUpdateAttributeValues(product, payload.getAttributes()); + return productRepository.save(product); } } \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/user/model/UserEntity.java b/backend/src/main/java/net/pinehaus/backend/user/model/UserEntity.java index 53fa0d5..159ed3f 100644 --- a/backend/src/main/java/net/pinehaus/backend/user/model/UserEntity.java +++ b/backend/src/main/java/net/pinehaus/backend/user/model/UserEntity.java @@ -29,6 +29,7 @@ public class UserEntity { @Id @GeneratedValue(strategy = GenerationType.UUID) + @JsonView(UserViews.Public.class) private UUID id; @Column(nullable = false) @@ -40,15 +41,19 @@ public class UserEntity { @JsonView(UserViews.Public.class) private String lastName; + @JsonView(UserViews.Public.class) private String username; @Column(nullable = false) + @JsonView(UserViews.Public.class) private String email; @Column + @JsonView(UserViews.Public.class) private Date dateOfBirth; @Column + @JsonView(UserViews.Public.class) private String avatarUrl; @@ -61,15 +66,12 @@ public class UserEntity { * token. */ @Column - @JsonIgnore private String googleId; @Column(length = 2048) - @JsonIgnore private String sessionId; @Column - @JsonIgnore private Timestamp sessionExpiresAt; @CreationTimestamp From 5ec698e18321f74d1f44d1c7aea1222c85e2d780 Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Wed, 1 May 2024 14:15:48 +0200 Subject: [PATCH 16/50] [Frontend] Added product sort by price --- .../components/ProductList/ProductList.tsx | 46 +++++++++++++------ .../ProductList/components/Sort/Sort.tsx | 40 ++++++++++++++++ .../ProductList/components/Sort/index.ts | 1 + .../ProductList/components/index.ts | 1 + .../src/modules/Products/reducer/actions.ts | 7 ++- .../src/modules/Products/reducer/interface.ts | 4 +- .../src/modules/Products/reducer/reducer.ts | 9 ++++ 7 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 frontend/src/modules/Products/components/ProductList/components/Sort/Sort.tsx create mode 100644 frontend/src/modules/Products/components/ProductList/components/Sort/index.ts diff --git a/frontend/src/modules/Products/components/ProductList/ProductList.tsx b/frontend/src/modules/Products/components/ProductList/ProductList.tsx index 31370ba..6c51144 100644 --- a/frontend/src/modules/Products/components/ProductList/ProductList.tsx +++ b/frontend/src/modules/Products/components/ProductList/ProductList.tsx @@ -8,6 +8,7 @@ import { BreadcrumbLink, Button, Container, + Divider, Flex, useColorModeValue, useDisclosure, @@ -18,8 +19,8 @@ import { ProductSlot } from 'components/Product' import { Product } from 'model/Product' import { ProductsProvider, useProductsDispatch, useProductsState } from 'modules/Products/context' import { setCurrentPage, setProducts } from 'modules/Products/reducer/actions' -import { useEffect, useRef } from 'react' -import { Filters } from './components' +import { useEffect, useRef, useState } from 'react' +import { Filters, Sort } from './components' interface Props { products: Product[] @@ -29,6 +30,8 @@ interface Props { function ProductList({}: Props) { const isFirstLoad = useRef(true) + const [isLoading, setIsLoading] = useState(false) + const { products, currentPage, totalPages, size, sort, filters } = useProductsState() const dispatch = useProductsDispatch() @@ -42,9 +45,12 @@ function ProductList({}: Props) { return } + setIsLoading(true) + getProducts({ page: currentPage - 1, size, sort, min: filters.min, max: filters.max }) .then(({ products, totalPages }) => dispatch(setProducts(products, totalPages))) .catch(console.error) + .finally(() => setIsLoading(false)) }, [currentPage, size, sort, filters]) const handlePageChange = (page: number) => dispatch(setCurrentPage(page)) @@ -53,12 +59,20 @@ function ProductList({}: Props) { <> - + + + + + + + + + @@ -68,11 +82,17 @@ function ProductList({}: Props) { - - {products.map(product => ( - - ))} - + {isLoading ? ( + + Loading... + + ) : ( + + {products.map(product => ( + + ))} + + )} diff --git a/frontend/src/modules/Products/components/ProductList/components/Sort/Sort.tsx b/frontend/src/modules/Products/components/ProductList/components/Sort/Sort.tsx new file mode 100644 index 0000000..5daa820 --- /dev/null +++ b/frontend/src/modules/Products/components/ProductList/components/Sort/Sort.tsx @@ -0,0 +1,40 @@ +import { ChevronDown, ChevronUp } from '@carbon/icons-react' +import { Box, Flex, Text } from '@chakra-ui/react' +import { useProductsDispatch, useProductsState } from 'modules/Products/context' +import { toggleSort } from 'modules/Products/reducer/actions' + +export default function Sort() { + const { sort } = useProductsState() + + const dispatch = useProductsDispatch() + + const handleSwitchSort = () => { + dispatch(toggleSort()) + } + + return ( + + + Sort: + + + + {sort === 'asc' ? ( + <> + Ascending + + + + + ) : ( + <> + Descending + + + + + )} + + + ) +} diff --git a/frontend/src/modules/Products/components/ProductList/components/Sort/index.ts b/frontend/src/modules/Products/components/ProductList/components/Sort/index.ts new file mode 100644 index 0000000..e8b7e40 --- /dev/null +++ b/frontend/src/modules/Products/components/ProductList/components/Sort/index.ts @@ -0,0 +1 @@ +export { default } from './Sort' diff --git a/frontend/src/modules/Products/components/ProductList/components/index.ts b/frontend/src/modules/Products/components/ProductList/components/index.ts index b8da57a..611c8d5 100644 --- a/frontend/src/modules/Products/components/ProductList/components/index.ts +++ b/frontend/src/modules/Products/components/ProductList/components/index.ts @@ -1 +1,2 @@ export { default as Filters } from './Filters' +export { default as Sort } from './Sort' diff --git a/frontend/src/modules/Products/reducer/actions.ts b/frontend/src/modules/Products/reducer/actions.ts index 4f422cc..ffdcc1f 100644 --- a/frontend/src/modules/Products/reducer/actions.ts +++ b/frontend/src/modules/Products/reducer/actions.ts @@ -1,5 +1,5 @@ import { Product } from 'model/Product' -import { SetCurrentPageAction, SetFiltersAction, SetProductsAction } from './interface' +import { SetCurrentPageAction, SetFiltersAction, SetProductsAction, ToggleSortAction } from './interface' import { IProductsState } from '../interface' /** Products */ @@ -22,3 +22,8 @@ export const setFilters = (filters: IProductsState['filters']): SetFiltersAction type: 'SET_FILTERS', payload: filters, }) + +export const toggleSort = (): ToggleSortAction => ({ + type: 'TOGGLE_SORT', + payload: undefined, +}) diff --git a/frontend/src/modules/Products/reducer/interface.ts b/frontend/src/modules/Products/reducer/interface.ts index 75abc7a..45de550 100644 --- a/frontend/src/modules/Products/reducer/interface.ts +++ b/frontend/src/modules/Products/reducer/interface.ts @@ -14,4 +14,6 @@ export type SetCurrentPageAction = Action<'SET_CURRENT_PAGE', number> export type SetFiltersAction = Action<'SET_FILTERS', IProductsState['filters']> -export type ProductsActions = SetProductsAction | SetCurrentPageAction | SetFiltersAction +export type ToggleSortAction = Action<'TOGGLE_SORT', undefined> + +export type ProductsActions = SetProductsAction | SetCurrentPageAction | SetFiltersAction | ToggleSortAction diff --git a/frontend/src/modules/Products/reducer/reducer.ts b/frontend/src/modules/Products/reducer/reducer.ts index 2d5a7dc..dd67cd2 100644 --- a/frontend/src/modules/Products/reducer/reducer.ts +++ b/frontend/src/modules/Products/reducer/reducer.ts @@ -3,7 +3,9 @@ import { ProductsActions } from './interface' export const SET_PRODUCTS = 'SET_PRODUCTS' export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE' + export const SET_FILTERS = 'SET_FILTERS' +export const TOGGLE_SORT = 'TOGGLE_SORT' export const reducer = (state: IProductsState, action: ProductsActions): IProductsState => { switch (action.type) { @@ -35,6 +37,13 @@ export const reducer = (state: IProductsState, action: ProductsActions): IProduc } } + case TOGGLE_SORT: { + return { + ...state, + sort: state.sort === 'asc' ? 'desc' : 'asc', + } + } + default: return state } From b486dc97b09cecc3f4c86365b4dbcfc787812ac4 Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Wed, 8 May 2024 10:46:24 +0200 Subject: [PATCH 17/50] [Frontend] Product attributes --- frontend/src/model/Product/Product.ts | 4 ++-- .../src/model/Product/ProductAttribute.ts | 6 +++++ frontend/src/modules/Product/ProductPage.tsx | 24 +++++++++++++++++-- frontend/src/utils/api/utils.ts | 2 +- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/frontend/src/model/Product/Product.ts b/frontend/src/model/Product/Product.ts index ee13434..ce5d09f 100644 --- a/frontend/src/model/Product/Product.ts +++ b/frontend/src/model/Product/Product.ts @@ -1,6 +1,6 @@ import { Category } from 'model/Category' import { User } from 'model/User' -import { ProductAttribute } from './ProductAttribute' +import { ProductAttributeValue } from './ProductAttribute' export interface Product { id: number @@ -10,7 +10,7 @@ export interface Product { sku: string quantity: number price: number - attributes: ProductAttribute[] + attributes: ProductAttributeValue[] createdBy: User category: Category thumbnail: string diff --git a/frontend/src/model/Product/ProductAttribute.ts b/frontend/src/model/Product/ProductAttribute.ts index e855fc8..c15e8f4 100644 --- a/frontend/src/model/Product/ProductAttribute.ts +++ b/frontend/src/model/Product/ProductAttribute.ts @@ -10,4 +10,10 @@ export interface ProductAttribute { name: string type: ProductAttributeType value: string + options: string[] | null +} + +export interface ProductAttributeValue { + attribute: ProductAttribute + value: string } diff --git a/frontend/src/modules/Product/ProductPage.tsx b/frontend/src/modules/Product/ProductPage.tsx index 0d584a7..b54da3d 100644 --- a/frontend/src/modules/Product/ProductPage.tsx +++ b/frontend/src/modules/Product/ProductPage.tsx @@ -46,14 +46,14 @@ export default function ProductPage({ product }: Props) { - {product.category.name} + {product.name} - + {product.name} @@ -79,6 +79,26 @@ export default function ProductPage({ product }: Props) { Add to cart + + + + + Details: + + + {product.attributes.map(({ attribute, value }) => ( + + + {attribute.name} + + + + : + + + {value} + + ))} diff --git a/frontend/src/utils/api/utils.ts b/frontend/src/utils/api/utils.ts index 8bf22ac..746b5f1 100644 --- a/frontend/src/utils/api/utils.ts +++ b/frontend/src/utils/api/utils.ts @@ -39,7 +39,7 @@ export function parseResponse(response: Response) { export async function getJson(url: string) { const response = await fetch(url, { - // credentials: 'include', + credentials: 'include', }) return parseResponse(response) From 2b5eac75910b10feb418526aed3abbf43b32348a Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Sun, 12 May 2024 21:23:13 +0200 Subject: [PATCH 18/50] [Backend] Image controller --- .env.example | 1 + .../image/controller/ImageController.java | 47 ++++++++++++++ .../backend/image/service/ImageService.java | 62 +++++++++++++++++++ backend/src/main/resources/application.yml | 2 + 4 files changed, 112 insertions(+) create mode 100644 backend/src/main/java/net/pinehaus/backend/image/controller/ImageController.java create mode 100644 backend/src/main/java/net/pinehaus/backend/image/service/ImageService.java diff --git a/.env.example b/.env.example index 4c015fb..2e29a38 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ BACKEND_DATASOURCE_URL=jdbc:postgresql://pinehaus-database-staging:5432/database BACKEND_DATASOURCE_USERNAME=admin BACKEND_DATASOURCE_PASSWORD=123 GOOGLE_CLIENT_ID=id.apps.googleusercontent.com +IMAGE_PATH=C:\Users\jerza\Documents\code\test FRONTEND_COOKIE_DOMAIN=localhost FRONTEND_URL=https://localhost:3000 diff --git a/backend/src/main/java/net/pinehaus/backend/image/controller/ImageController.java b/backend/src/main/java/net/pinehaus/backend/image/controller/ImageController.java new file mode 100644 index 0000000..7672af4 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/image/controller/ImageController.java @@ -0,0 +1,47 @@ +package net.pinehaus.backend.image.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.HashMap; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.pinehaus.backend.image.service.ImageService; +import net.pinehaus.backend.util.ResponseUtilities; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@Tag(name = "Images") +@RequestMapping("/images") +@RequiredArgsConstructor +@PreAuthorize("hasAuthority('USER')") +@Slf4j +public class ImageController { + + private final ImageService imageService; + + @PostMapping + @Operation(summary = "Upload an image.", + description = "Upload an image file to the server.") + @ApiResponses({@ApiResponse(responseCode = "204")}) + public ResponseEntity> uploadImage( + @RequestParam("file") MultipartFile file) { + try { + imageService.saveImage(file); + return new ResponseEntity<>(ResponseUtilities.successResponse(), HttpStatus.NO_CONTENT); + } catch (Exception e) { + log.error("failed to upload image", e); + + return new ResponseEntity<>(ResponseUtilities.errorResponse(e.getMessage()), + HttpStatus.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/image/service/ImageService.java b/backend/src/main/java/net/pinehaus/backend/image/service/ImageService.java new file mode 100644 index 0000000..56a7a9b --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/image/service/ImageService.java @@ -0,0 +1,62 @@ +package net.pinehaus.backend.image.service; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; +import javax.imageio.ImageIO; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +public class ImageService { + + @Value("${app.image.path}") + public String UPLOAD_DIR; + private static final long MAX_FILE_SIZE = 10485760; // 10MB + private static final List ALLOWED_MIME_TYPES = Arrays.asList("image/jpeg", "image/png"); + private static final List ALLOWED_EXTENSIONS = Arrays.asList(".jpg", ".jpeg", ".png"); + + public void saveImage(MultipartFile file) throws IOException { + validateImage(file); + Path path = Paths.get(getUploadDir() + file.getOriginalFilename()); + Files.write(path, file.getBytes()); + } + + private String getUploadDir() { + if (!UPLOAD_DIR.endsWith(File.separator)) { + return UPLOAD_DIR + File.separator; + + } + + return UPLOAD_DIR; + } + + private void validateImage(MultipartFile file) throws IOException { + if (file.getSize() > MAX_FILE_SIZE) { + throw new IOException("File size exceeds limit"); + } + + String mimeType = file.getContentType(); + if (mimeType == null || !ALLOWED_MIME_TYPES.contains(mimeType)) { + throw new IOException("Invalid MIME type"); + } + + String filename = file.getOriginalFilename(); + assert filename != null; + String extension = filename.substring(filename.lastIndexOf(".")); + if (!ALLOWED_EXTENSIONS.contains(extension)) { + throw new IOException("Invalid file extension"); + } + + BufferedImage image = ImageIO.read(file.getInputStream()); + if (image == null) { + throw new IOException("File is not an image"); + } + } +} \ No newline at end of file diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index af21bb7..48c012f 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -16,6 +16,8 @@ app: frontend: url: ${FRONTEND_URL} cookieDomain: ${FRONTEND_COOKIE_DOMAIN} + image: + path: ${IMAGE_PATH} spring: datasource: From 9b57e06d1439117e0d08fd72c9d27d1a6e2ec03f Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Wed, 8 May 2024 11:45:31 +0200 Subject: [PATCH 19/50] [Frontend] Product attribute options --- frontend/src/modules/Product/ProductPage.tsx | 34 ++++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/frontend/src/modules/Product/ProductPage.tsx b/frontend/src/modules/Product/ProductPage.tsx index b54da3d..c9a0111 100644 --- a/frontend/src/modules/Product/ProductPage.tsx +++ b/frontend/src/modules/Product/ProductPage.tsx @@ -26,6 +26,10 @@ interface Props { export default function ProductPage({ product }: Props) { const breadcrumbBarBg = useColorModeValue('yellow.200', 'orange.700') + const hasAttributesWithOptions = product.attributes.some( + ({ attribute }) => !!attribute.options && attribute.options.length > 0 + ) + return ( <> @@ -63,15 +67,36 @@ export default function ProductPage({ product }: Props) { {product.price} € - - {product.description} - + + {hasAttributesWithOptions && ( + + + Select options: + + + {product.attributes + .filter(({ attribute }) => !!attribute.options?.length) + .map(({ attribute }) => ( + + {attribute.name} + + + {attribute.options?.map(option => ( + + ))} + + + ))} + + )} @@ -79,13 +104,10 @@ export default function ProductPage({ product }: Props) { Add to cart - - Details: - {product.attributes.map(({ attribute, value }) => ( From d058f0bf0c9daae431f46ff7275d9d315632b06e Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Wed, 8 May 2024 22:15:40 +0200 Subject: [PATCH 20/50] [Frontend] WIP product form --- .../app/products/[id]/[slug]/edit/page.tsx | 16 ++++ .../{[[...productInfo]] => [slug]}/page.tsx | 4 +- frontend/src/app/products/create/page.tsx | 3 + frontend/src/app/products/page.tsx | 2 +- .../modules/Products/Edit/ProductEditPage.tsx | 19 ++++ .../components/ProductForm/ProductForm.tsx | 89 +++++++++++++++++++ .../Edit/components/ProductForm/index.ts | 1 + .../Edit/components/ProductForm/interface.ts | 13 +++ .../Edit/components/ProductForm/utils.ts | 32 +++++++ .../modules/Products/Edit/components/index.ts | 1 + frontend/src/modules/Products/Edit/index.ts | 1 + .../modules/Products/{ => List}/Products.tsx | 0 .../components/ProductList/ProductList.tsx | 4 +- .../components/Filters/Filters.tsx | 4 +- .../ProductList/components/Filters/index.ts | 0 .../ProductList/components/Sort/Sort.tsx | 4 +- .../ProductList/components/Sort/index.ts | 0 .../ProductList/components/index.ts | 0 .../components/ProductList/index.ts | 0 .../Products/{ => List}/components/index.ts | 0 .../src/modules/Products/{ => List}/const.ts | 0 .../modules/Products/{ => List}/context.tsx | 0 .../src/modules/Products/{ => List}/index.ts | 0 .../modules/Products/{ => List}/interface.ts | 0 .../Products/{ => List}/reducer/actions.ts | 0 .../Products/{ => List}/reducer/interface.ts | 0 .../Products/{ => List}/reducer/reducer.ts | 0 .../{ => Products}/Product/ProductPage.tsx | 0 .../modules/{ => Products}/Product/index.ts | 0 29 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 frontend/src/app/products/[id]/[slug]/edit/page.tsx rename frontend/src/app/products/[id]/{[[...productInfo]] => [slug]}/page.tsx (70%) create mode 100644 frontend/src/app/products/create/page.tsx create mode 100644 frontend/src/modules/Products/Edit/ProductEditPage.tsx create mode 100644 frontend/src/modules/Products/Edit/components/ProductForm/ProductForm.tsx create mode 100644 frontend/src/modules/Products/Edit/components/ProductForm/index.ts create mode 100644 frontend/src/modules/Products/Edit/components/ProductForm/interface.ts create mode 100644 frontend/src/modules/Products/Edit/components/ProductForm/utils.ts create mode 100644 frontend/src/modules/Products/Edit/components/index.ts create mode 100644 frontend/src/modules/Products/Edit/index.ts rename frontend/src/modules/Products/{ => List}/Products.tsx (100%) rename frontend/src/modules/Products/{ => List}/components/ProductList/ProductList.tsx (96%) rename frontend/src/modules/Products/{ => List}/components/ProductList/components/Filters/Filters.tsx (97%) rename frontend/src/modules/Products/{ => List}/components/ProductList/components/Filters/index.ts (100%) rename frontend/src/modules/Products/{ => List}/components/ProductList/components/Sort/Sort.tsx (91%) rename frontend/src/modules/Products/{ => List}/components/ProductList/components/Sort/index.ts (100%) rename frontend/src/modules/Products/{ => List}/components/ProductList/components/index.ts (100%) rename frontend/src/modules/Products/{ => List}/components/ProductList/index.ts (100%) rename frontend/src/modules/Products/{ => List}/components/index.ts (100%) rename frontend/src/modules/Products/{ => List}/const.ts (100%) rename frontend/src/modules/Products/{ => List}/context.tsx (100%) rename frontend/src/modules/Products/{ => List}/index.ts (100%) rename frontend/src/modules/Products/{ => List}/interface.ts (100%) rename frontend/src/modules/Products/{ => List}/reducer/actions.ts (100%) rename frontend/src/modules/Products/{ => List}/reducer/interface.ts (100%) rename frontend/src/modules/Products/{ => List}/reducer/reducer.ts (100%) rename frontend/src/modules/{ => Products}/Product/ProductPage.tsx (100%) rename frontend/src/modules/{ => Products}/Product/index.ts (100%) diff --git a/frontend/src/app/products/[id]/[slug]/edit/page.tsx b/frontend/src/app/products/[id]/[slug]/edit/page.tsx new file mode 100644 index 0000000..daacf1b --- /dev/null +++ b/frontend/src/app/products/[id]/[slug]/edit/page.tsx @@ -0,0 +1,16 @@ +import { getProduct } from 'api/Product/repository' +import ProductEditPage from 'modules/Products/Edit' + +interface Props { + params: { + id: string + } +} + +export default async function Page({ params }: Props) { + const productId = Number(params.id) + + const product = await getProduct(productId) + + return +} diff --git a/frontend/src/app/products/[id]/[[...productInfo]]/page.tsx b/frontend/src/app/products/[id]/[slug]/page.tsx similarity index 70% rename from frontend/src/app/products/[id]/[[...productInfo]]/page.tsx rename to frontend/src/app/products/[id]/[slug]/page.tsx index b8f8fde..489cac3 100644 --- a/frontend/src/app/products/[id]/[[...productInfo]]/page.tsx +++ b/frontend/src/app/products/[id]/[slug]/page.tsx @@ -1,15 +1,13 @@ import { getProduct } from 'api/Product/repository' -import { ProductPage } from 'modules/Product' +import { ProductPage } from 'modules/Products/Product' interface Props { params: { - productInfo: string[] id: string } } export default async function Page({ params }: Props) { - const [productSlug] = params.productInfo ?? [] const productId = Number(params.id) const product = await getProduct(productId) diff --git a/frontend/src/app/products/create/page.tsx b/frontend/src/app/products/create/page.tsx new file mode 100644 index 0000000..110a284 --- /dev/null +++ b/frontend/src/app/products/create/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
page
+} diff --git a/frontend/src/app/products/page.tsx b/frontend/src/app/products/page.tsx index 8c6aacc..35ea01a 100644 --- a/frontend/src/app/products/page.tsx +++ b/frontend/src/app/products/page.tsx @@ -1,6 +1,6 @@ import { ChevronRight } from '@carbon/icons-react' import { Box, Breadcrumb, BreadcrumbItem, BreadcrumbLink, Container, Flex, Text } from '@chakra-ui/react' -import { Products } from 'modules/Products' +import { Products } from 'modules/Products/List' import Image from 'next/image' import { Suspense } from 'react' diff --git a/frontend/src/modules/Products/Edit/ProductEditPage.tsx b/frontend/src/modules/Products/Edit/ProductEditPage.tsx new file mode 100644 index 0000000..7ce2139 --- /dev/null +++ b/frontend/src/modules/Products/Edit/ProductEditPage.tsx @@ -0,0 +1,19 @@ +'use client' + +import { Product } from 'model/Product' +import { ProductForm } from './components' +import { Container } from '@chakra-ui/react' + +interface Props { + product: Product +} + +export default function ProductEditPage({ product }: Props) { + return ( + <> + + {}} w="100%" /> + + + ) +} diff --git a/frontend/src/modules/Products/Edit/components/ProductForm/ProductForm.tsx b/frontend/src/modules/Products/Edit/components/ProductForm/ProductForm.tsx new file mode 100644 index 0000000..c1775c7 --- /dev/null +++ b/frontend/src/modules/Products/Edit/components/ProductForm/ProductForm.tsx @@ -0,0 +1,89 @@ +import { Formik, FormikHelpers, useFormikContext } from 'formik' +import { Product } from 'model/Product' +import { ProductFormValidationSchema, mapProductToUserFormValues } from './utils' +import { ProductFormValues } from './interface' +import { + Box, + Flex, + FlexProps, + FormControl, + FormErrorMessage, + FormLabel, + Input, + NumberInput, + NumberInputField, + Text, +} from '@chakra-ui/react' + +interface Props extends FlexProps { + handleSubmit: (values: ProductFormValues, formikHelpers: FormikHelpers) => void + product?: Product +} + +function ProductForm({ isNew }: { isNew: boolean }) { + const { values, errors, setFieldValue } = useFormikContext() + + const handleFirstNameChange = (field: string) => (e: React.ChangeEvent) => + setFieldValue(field, e.target.value) + + const handleNumberChange = (field: string) => (value: string) => setFieldValue(field, value) + + return ( + <> + {isNew ? 'Create new product' : 'Update product'} + + {/* Product form fields */} + + + Product name + + {!!errors.name && {errors.name}} + + + + SKU + + {!!errors.sku && {errors.sku}} + + + + + + + Price + + + + {!!errors.price && {errors.price}} + + + + Quantity in stock + + + + {!!errors.quantity && {errors.quantity}} + + + + + + + ) +} + +export default function ProductFormWrapper({ product, handleSubmit, ...flexProps }: Props) { + return ( + + + + + + ) +} diff --git a/frontend/src/modules/Products/Edit/components/ProductForm/index.ts b/frontend/src/modules/Products/Edit/components/ProductForm/index.ts new file mode 100644 index 0000000..a0e8a76 --- /dev/null +++ b/frontend/src/modules/Products/Edit/components/ProductForm/index.ts @@ -0,0 +1 @@ +export { default } from './ProductForm' diff --git a/frontend/src/modules/Products/Edit/components/ProductForm/interface.ts b/frontend/src/modules/Products/Edit/components/ProductForm/interface.ts new file mode 100644 index 0000000..3924918 --- /dev/null +++ b/frontend/src/modules/Products/Edit/components/ProductForm/interface.ts @@ -0,0 +1,13 @@ +export interface ProductFormValues { + name: string + description: string + sku: string + quantity: number + price?: number + attributes: Array<{ + attributeId: number + value: string + }> + categoryId?: number + thumbnail: string +} diff --git a/frontend/src/modules/Products/Edit/components/ProductForm/utils.ts b/frontend/src/modules/Products/Edit/components/ProductForm/utils.ts new file mode 100644 index 0000000..f3373f6 --- /dev/null +++ b/frontend/src/modules/Products/Edit/components/ProductForm/utils.ts @@ -0,0 +1,32 @@ +import { Product } from 'model/Product' +import * as yup from 'yup' +import { ProductFormValues } from './interface' + +export const ProductFormValidationSchema = yup.object().shape({}) + +export const mapProductToUserFormValues = (product?: Product): ProductFormValues => { + if (!product) { + return { + name: '', + description: '', + sku: '', + quantity: 0, + thumbnail: '', + attributes: [], + } + } + + return { + name: product.name, + description: product.description, + price: product.price, + categoryId: product.category.id, + sku: product.sku, + quantity: product.quantity, + thumbnail: product.thumbnail, + attributes: product.attributes.map(attribute => ({ + attributeId: attribute.attribute.id, + value: attribute.value, + })), + } +} diff --git a/frontend/src/modules/Products/Edit/components/index.ts b/frontend/src/modules/Products/Edit/components/index.ts new file mode 100644 index 0000000..a18561a --- /dev/null +++ b/frontend/src/modules/Products/Edit/components/index.ts @@ -0,0 +1 @@ +export { default as ProductForm } from './ProductForm' diff --git a/frontend/src/modules/Products/Edit/index.ts b/frontend/src/modules/Products/Edit/index.ts new file mode 100644 index 0000000..4496e26 --- /dev/null +++ b/frontend/src/modules/Products/Edit/index.ts @@ -0,0 +1 @@ +export { default } from './ProductEditPage' diff --git a/frontend/src/modules/Products/Products.tsx b/frontend/src/modules/Products/List/Products.tsx similarity index 100% rename from frontend/src/modules/Products/Products.tsx rename to frontend/src/modules/Products/List/Products.tsx diff --git a/frontend/src/modules/Products/components/ProductList/ProductList.tsx b/frontend/src/modules/Products/List/components/ProductList/ProductList.tsx similarity index 96% rename from frontend/src/modules/Products/components/ProductList/ProductList.tsx rename to frontend/src/modules/Products/List/components/ProductList/ProductList.tsx index 6c51144..6558359 100644 --- a/frontend/src/modules/Products/components/ProductList/ProductList.tsx +++ b/frontend/src/modules/Products/List/components/ProductList/ProductList.tsx @@ -17,8 +17,8 @@ import { getProducts } from 'api/Product/repository' import Pagination from 'components/Pagination' import { ProductSlot } from 'components/Product' import { Product } from 'model/Product' -import { ProductsProvider, useProductsDispatch, useProductsState } from 'modules/Products/context' -import { setCurrentPage, setProducts } from 'modules/Products/reducer/actions' +import { ProductsProvider, useProductsDispatch, useProductsState } from 'modules/Products/List/context' +import { setCurrentPage, setProducts } from 'modules/Products/List/reducer/actions' import { useEffect, useRef, useState } from 'react' import { Filters, Sort } from './components' diff --git a/frontend/src/modules/Products/components/ProductList/components/Filters/Filters.tsx b/frontend/src/modules/Products/List/components/ProductList/components/Filters/Filters.tsx similarity index 97% rename from frontend/src/modules/Products/components/ProductList/components/Filters/Filters.tsx rename to frontend/src/modules/Products/List/components/ProductList/components/Filters/Filters.tsx index f281a55..5c5450c 100644 --- a/frontend/src/modules/Products/components/ProductList/components/Filters/Filters.tsx +++ b/frontend/src/modules/Products/List/components/ProductList/components/Filters/Filters.tsx @@ -16,8 +16,8 @@ import { ModalOverlay, Text, } from '@chakra-ui/react' -import { useProductsDispatch, useProductsState } from 'modules/Products/context' -import { setFilters } from 'modules/Products/reducer/actions' +import { useProductsDispatch, useProductsState } from 'modules/Products/List/context' +import { setFilters } from 'modules/Products/List/reducer/actions' import { useEffect, useState } from 'react' interface Props { diff --git a/frontend/src/modules/Products/components/ProductList/components/Filters/index.ts b/frontend/src/modules/Products/List/components/ProductList/components/Filters/index.ts similarity index 100% rename from frontend/src/modules/Products/components/ProductList/components/Filters/index.ts rename to frontend/src/modules/Products/List/components/ProductList/components/Filters/index.ts diff --git a/frontend/src/modules/Products/components/ProductList/components/Sort/Sort.tsx b/frontend/src/modules/Products/List/components/ProductList/components/Sort/Sort.tsx similarity index 91% rename from frontend/src/modules/Products/components/ProductList/components/Sort/Sort.tsx rename to frontend/src/modules/Products/List/components/ProductList/components/Sort/Sort.tsx index 5daa820..cfc3039 100644 --- a/frontend/src/modules/Products/components/ProductList/components/Sort/Sort.tsx +++ b/frontend/src/modules/Products/List/components/ProductList/components/Sort/Sort.tsx @@ -1,7 +1,7 @@ import { ChevronDown, ChevronUp } from '@carbon/icons-react' import { Box, Flex, Text } from '@chakra-ui/react' -import { useProductsDispatch, useProductsState } from 'modules/Products/context' -import { toggleSort } from 'modules/Products/reducer/actions' +import { useProductsDispatch, useProductsState } from 'modules/Products/List/context' +import { toggleSort } from 'modules/Products/List/reducer/actions' export default function Sort() { const { sort } = useProductsState() diff --git a/frontend/src/modules/Products/components/ProductList/components/Sort/index.ts b/frontend/src/modules/Products/List/components/ProductList/components/Sort/index.ts similarity index 100% rename from frontend/src/modules/Products/components/ProductList/components/Sort/index.ts rename to frontend/src/modules/Products/List/components/ProductList/components/Sort/index.ts diff --git a/frontend/src/modules/Products/components/ProductList/components/index.ts b/frontend/src/modules/Products/List/components/ProductList/components/index.ts similarity index 100% rename from frontend/src/modules/Products/components/ProductList/components/index.ts rename to frontend/src/modules/Products/List/components/ProductList/components/index.ts diff --git a/frontend/src/modules/Products/components/ProductList/index.ts b/frontend/src/modules/Products/List/components/ProductList/index.ts similarity index 100% rename from frontend/src/modules/Products/components/ProductList/index.ts rename to frontend/src/modules/Products/List/components/ProductList/index.ts diff --git a/frontend/src/modules/Products/components/index.ts b/frontend/src/modules/Products/List/components/index.ts similarity index 100% rename from frontend/src/modules/Products/components/index.ts rename to frontend/src/modules/Products/List/components/index.ts diff --git a/frontend/src/modules/Products/const.ts b/frontend/src/modules/Products/List/const.ts similarity index 100% rename from frontend/src/modules/Products/const.ts rename to frontend/src/modules/Products/List/const.ts diff --git a/frontend/src/modules/Products/context.tsx b/frontend/src/modules/Products/List/context.tsx similarity index 100% rename from frontend/src/modules/Products/context.tsx rename to frontend/src/modules/Products/List/context.tsx diff --git a/frontend/src/modules/Products/index.ts b/frontend/src/modules/Products/List/index.ts similarity index 100% rename from frontend/src/modules/Products/index.ts rename to frontend/src/modules/Products/List/index.ts diff --git a/frontend/src/modules/Products/interface.ts b/frontend/src/modules/Products/List/interface.ts similarity index 100% rename from frontend/src/modules/Products/interface.ts rename to frontend/src/modules/Products/List/interface.ts diff --git a/frontend/src/modules/Products/reducer/actions.ts b/frontend/src/modules/Products/List/reducer/actions.ts similarity index 100% rename from frontend/src/modules/Products/reducer/actions.ts rename to frontend/src/modules/Products/List/reducer/actions.ts diff --git a/frontend/src/modules/Products/reducer/interface.ts b/frontend/src/modules/Products/List/reducer/interface.ts similarity index 100% rename from frontend/src/modules/Products/reducer/interface.ts rename to frontend/src/modules/Products/List/reducer/interface.ts diff --git a/frontend/src/modules/Products/reducer/reducer.ts b/frontend/src/modules/Products/List/reducer/reducer.ts similarity index 100% rename from frontend/src/modules/Products/reducer/reducer.ts rename to frontend/src/modules/Products/List/reducer/reducer.ts diff --git a/frontend/src/modules/Product/ProductPage.tsx b/frontend/src/modules/Products/Product/ProductPage.tsx similarity index 100% rename from frontend/src/modules/Product/ProductPage.tsx rename to frontend/src/modules/Products/Product/ProductPage.tsx diff --git a/frontend/src/modules/Product/index.ts b/frontend/src/modules/Products/Product/index.ts similarity index 100% rename from frontend/src/modules/Product/index.ts rename to frontend/src/modules/Products/Product/index.ts From bf1dacfd5b034acd49d311488fa285cf349aa3f6 Mon Sep 17 00:00:00 2001 From: Ivan Jerzabek Date: Sun, 12 May 2024 21:24:11 +0200 Subject: [PATCH 21/50] [Frontend] Edit product form --- .../RevalidateProductAction.tsx | 7 + .../actions/RevalidateProductAction/index.ts | 1 + frontend/src/actions/index.ts | 1 + frontend/src/api/Attribute/interface.ts | 3 + frontend/src/api/Attribute/repository.ts | 5 + frontend/src/api/Attribute/routes.ts | 3 + frontend/src/api/Category/interface.ts | 3 + frontend/src/api/Category/repository.ts | 5 + frontend/src/api/Category/routes.ts | 3 + frontend/src/api/Product/repository.ts | 12 +- frontend/src/api/Product/routes.ts | 4 +- .../src/model/Product/ProductAttribute.ts | 24 ++- frontend/src/model/User/Product/Product.ts | 17 -- .../model/User/Product/ProductAttribute.ts | 13 -- frontend/src/model/User/Product/index.ts | 2 - .../modules/Products/Edit/ProductEditPage.tsx | 38 +++- .../components/ProductForm/ProductForm.tsx | 87 ++++++++- .../components/Attributes/Attributes.tsx | 172 ++++++++++++++++++ .../DeleteAttributeModal.tsx | 45 +++++ .../components/DeleteAttributeModal/index.ts | 1 + .../EditAttributeModal/EditAttributeModal.tsx | 150 +++++++++++++++ .../components/EditAttributeModal/index.ts | 1 + .../components/Attributes/components/index.ts | 2 + .../components/Attributes/index.ts | 1 + .../ProductForm/components/index.ts | 1 + .../Edit/components/ProductForm/index.ts | 1 + frontend/src/utils/api/utils.ts | 3 +- 27 files changed, 552 insertions(+), 53 deletions(-) create mode 100644 frontend/src/actions/RevalidateProductAction/RevalidateProductAction.tsx create mode 100644 frontend/src/actions/RevalidateProductAction/index.ts create mode 100644 frontend/src/actions/index.ts create mode 100644 frontend/src/api/Attribute/interface.ts create mode 100644 frontend/src/api/Attribute/repository.ts create mode 100644 frontend/src/api/Attribute/routes.ts create mode 100644 frontend/src/api/Category/interface.ts create mode 100644 frontend/src/api/Category/repository.ts create mode 100644 frontend/src/api/Category/routes.ts delete mode 100644 frontend/src/model/User/Product/Product.ts delete mode 100644 frontend/src/model/User/Product/ProductAttribute.ts delete mode 100644 frontend/src/model/User/Product/index.ts create mode 100644 frontend/src/modules/Products/Edit/components/ProductForm/components/Attributes/Attributes.tsx create mode 100644 frontend/src/modules/Products/Edit/components/ProductForm/components/Attributes/components/DeleteAttributeModal/DeleteAttributeModal.tsx create mode 100644 frontend/src/modules/Products/Edit/components/ProductForm/components/Attributes/components/DeleteAttributeModal/index.ts create mode 100644 frontend/src/modules/Products/Edit/components/ProductForm/components/Attributes/components/EditAttributeModal/EditAttributeModal.tsx create mode 100644 frontend/src/modules/Products/Edit/components/ProductForm/components/Attributes/components/EditAttributeModal/index.ts create mode 100644 frontend/src/modules/Products/Edit/components/ProductForm/components/Attributes/components/index.ts create mode 100644 frontend/src/modules/Products/Edit/components/ProductForm/components/Attributes/index.ts create mode 100644 frontend/src/modules/Products/Edit/components/ProductForm/components/index.ts diff --git a/frontend/src/actions/RevalidateProductAction/RevalidateProductAction.tsx b/frontend/src/actions/RevalidateProductAction/RevalidateProductAction.tsx new file mode 100644 index 0000000..7762e27 --- /dev/null +++ b/frontend/src/actions/RevalidateProductAction/RevalidateProductAction.tsx @@ -0,0 +1,7 @@ +'use server' + +import { revalidatePath } from 'next/cache' + +export default async function RevalidateProductAction() { + revalidatePath('/products/[id]/[slug]', 'page') +} diff --git a/frontend/src/actions/RevalidateProductAction/index.ts b/frontend/src/actions/RevalidateProductAction/index.ts new file mode 100644 index 0000000..857dbd9 --- /dev/null +++ b/frontend/src/actions/RevalidateProductAction/index.ts @@ -0,0 +1 @@ +export { default } from './RevalidateProductAction' diff --git a/frontend/src/actions/index.ts b/frontend/src/actions/index.ts new file mode 100644 index 0000000..0bbc43a --- /dev/null +++ b/frontend/src/actions/index.ts @@ -0,0 +1 @@ +export { default as RevalidateProductAction } from './RevalidateProductAction' diff --git a/frontend/src/api/Attribute/interface.ts b/frontend/src/api/Attribute/interface.ts new file mode 100644 index 0000000..96ebaab --- /dev/null +++ b/frontend/src/api/Attribute/interface.ts @@ -0,0 +1,3 @@ +import { ProductAttribute } from 'model/Product' + +export type AttributesResponse = ProductAttribute[] diff --git a/frontend/src/api/Attribute/repository.ts b/frontend/src/api/Attribute/repository.ts new file mode 100644 index 0000000..41ca800 --- /dev/null +++ b/frontend/src/api/Attribute/repository.ts @@ -0,0 +1,5 @@ +import { getJson } from 'utils/api' +import * as I from './interface' +import * as R from './routes' + +export const getAttributes = () => getJson(R.getAttributes()) diff --git a/frontend/src/api/Attribute/routes.ts b/frontend/src/api/Attribute/routes.ts new file mode 100644 index 0000000..12d7e8d --- /dev/null +++ b/frontend/src/api/Attribute/routes.ts @@ -0,0 +1,3 @@ +import { BASE_API_URL } from 'api/routes' + +export const getAttributes = () => `${BASE_API_URL}/attributes` diff --git a/frontend/src/api/Category/interface.ts b/frontend/src/api/Category/interface.ts new file mode 100644 index 0000000..749d354 --- /dev/null +++ b/frontend/src/api/Category/interface.ts @@ -0,0 +1,3 @@ +import { Category } from 'model/Category' + +export type CategoriesResponse = Category[] diff --git a/frontend/src/api/Category/repository.ts b/frontend/src/api/Category/repository.ts new file mode 100644 index 0000000..cf4bece --- /dev/null +++ b/frontend/src/api/Category/repository.ts @@ -0,0 +1,5 @@ +import { getJson } from 'utils/api' +import * as I from './interface' +import * as R from './routes' + +export const getCategories = () => getJson(R.getCategories()) diff --git a/frontend/src/api/Category/routes.ts b/frontend/src/api/Category/routes.ts new file mode 100644 index 0000000..85d5dbe --- /dev/null +++ b/frontend/src/api/Category/routes.ts @@ -0,0 +1,3 @@ +import { BASE_API_URL } from 'api/routes' + +export const getCategories = () => `${BASE_API_URL}/categories` diff --git a/frontend/src/api/Product/repository.ts b/frontend/src/api/Product/repository.ts index 2bf231f..4af9eb4 100644 --- a/frontend/src/api/Product/repository.ts +++ b/frontend/src/api/Product/repository.ts @@ -1,7 +1,11 @@ -import { getJson } from 'utils/api' -import { GetProductResponse, ProductListFilters, ProductListResponse } from './interface' +import { Product } from 'model/Product' +import { ProductFormValues } from 'modules/Products/Edit/components/ProductForm' +import { getJson, putJson } from 'utils/api' +import * as I from './interface' import * as R from './routes' -export const getProducts = (query: ProductListFilters) => getJson(R.getProductList(query)) +export const getProducts = (query: I.ProductListFilters) => getJson(R.getProductList(query)) -export const getProduct = (id: number) => getJson(R.getProduct(id)) +export const getProduct = (id: number) => getJson(R.getProduct(id), {}) + +export const updateProduct = (id: number, payload: ProductFormValues) => putJson(R.updateProduct(id), payload) diff --git a/frontend/src/api/Product/routes.ts b/frontend/src/api/Product/routes.ts index 104157a..e8764d5 100644 --- a/frontend/src/api/Product/routes.ts +++ b/frontend/src/api/Product/routes.ts @@ -1,7 +1,9 @@ import { BASE_API_URL } from 'api/routes' -import { ProductListFilters } from './interface' import { query } from 'utils/api' +import { ProductListFilters } from './interface' export const getProductList = (queryObject: ProductListFilters) => `${BASE_API_URL}/products${query(queryObject)}` export const getProduct = (id: number) => `${BASE_API_URL}/products/${id}` + +export const updateProduct = (id: number) => `${BASE_API_URL}/products/${id}` diff --git a/frontend/src/model/Product/ProductAttribute.ts b/frontend/src/model/Product/ProductAttribute.ts index c15e8f4..fd09934 100644 --- a/frontend/src/model/Product/ProductAttribute.ts +++ b/frontend/src/model/Product/ProductAttribute.ts @@ -5,14 +5,32 @@ export enum ProductAttributeType { BOOLEAN = 'BOOLEAN', } -export interface ProductAttribute { +interface ProductAttributeCommon { id: number name: string - type: ProductAttributeType value: string - options: string[] | null } +export type ProductAttribute = ProductAttributeCommon & + ( + | { + type: ProductAttributeType.ENUM + options: string[] + } + | { + type: ProductAttributeType.STRING + options: string + } + | { + type: ProductAttributeType.NUMBER + options: string + } + | { + type: ProductAttributeType.BOOLEAN + options: string + } + ) + export interface ProductAttributeValue { attribute: ProductAttribute value: string diff --git a/frontend/src/model/User/Product/Product.ts b/frontend/src/model/User/Product/Product.ts deleted file mode 100644 index ee13434..0000000 --- a/frontend/src/model/User/Product/Product.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Category } from 'model/Category' -import { User } from 'model/User' -import { ProductAttribute } from './ProductAttribute' - -export interface Product { - id: number - slug: string - name: string - description: string - sku: string - quantity: number - price: number - attributes: ProductAttribute[] - createdBy: User - category: Category - thumbnail: string -} diff --git a/frontend/src/model/User/Product/ProductAttribute.ts b/frontend/src/model/User/Product/ProductAttribute.ts deleted file mode 100644 index e855fc8..0000000 --- a/frontend/src/model/User/Product/ProductAttribute.ts +++ /dev/null @@ -1,13 +0,0 @@ -export enum ProductAttributeType { - ENUM = 'ENUM', - STRING = 'STRING', - NUMBER = 'NUMBER', - BOOLEAN = 'BOOLEAN', -} - -export interface ProductAttribute { - id: number - name: string - type: ProductAttributeType - value: string -} diff --git a/frontend/src/model/User/Product/index.ts b/frontend/src/model/User/Product/index.ts deleted file mode 100644 index 1422706..0000000 --- a/frontend/src/model/User/Product/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { Product } from './Product' -export type { ProductAttribute } from './ProductAttribute' diff --git a/frontend/src/modules/Products/Edit/ProductEditPage.tsx b/frontend/src/modules/Products/Edit/ProductEditPage.tsx index 7ce2139..46867cf 100644 --- a/frontend/src/modules/Products/Edit/ProductEditPage.tsx +++ b/frontend/src/modules/Products/Edit/ProductEditPage.tsx @@ -1,18 +1,52 @@ 'use client' +import { Container, useToast } from '@chakra-ui/react' +import { RevalidateProductAction } from 'actions' +import { updateProduct } from 'api/Product/repository' +import { useErrorToast, useSavingToast, useSuccessToast } from 'components/Toast' +import { FormikHelpers } from 'formik' import { Product } from 'model/Product' +import { useRouter } from 'next/navigation' import { ProductForm } from './components' -import { Container } from '@chakra-ui/react' +import { ProductFormValues } from './components/ProductForm' interface Props { product: Product } export default function ProductEditPage({ product }: Props) { + const { close } = useToast() + + const showSavingToast = useSavingToast() + const showSuccessToast = useSuccessToast() + const showErrorToast = useErrorToast() + + const { push } = useRouter() + + const handleSubmit = (values: ProductFormValues, helpers: FormikHelpers) => { + const toastId = showSavingToast() + + updateProduct(product.id, values) + .then(RevalidateProductAction) + .then(() => { + showSuccessToast() + + push(`/products/${product.id}/${product.slug}`) + }) + .catch(e => { + console.error(e) + showErrorToast() + }) + .finally(() => { + helpers.setSubmitting(false) + close(toastId) + }) + } + return ( <> - {}} w="100%" /> + ) diff --git a/frontend/src/modules/Products/Edit/components/ProductForm/ProductForm.tsx b/frontend/src/modules/Products/Edit/components/ProductForm/ProductForm.tsx index c1775c7..3a40224 100644 --- a/frontend/src/modules/Products/Edit/components/ProductForm/ProductForm.tsx +++ b/frontend/src/modules/Products/Edit/components/ProductForm/ProductForm.tsx @@ -1,9 +1,7 @@ -import { Formik, FormikHelpers, useFormikContext } from 'formik' -import { Product } from 'model/Product' -import { ProductFormValidationSchema, mapProductToUserFormValues } from './utils' -import { ProductFormValues } from './interface' +import { Save } from '@carbon/icons-react' import { Box, + Button, Flex, FlexProps, FormControl, @@ -12,8 +10,19 @@ import { Input, NumberInput, NumberInputField, + Select, + Spinner, Text, + Textarea, } from '@chakra-ui/react' +import { getCategories } from 'api/Category/repository' +import { Formik, FormikHelpers, useFormikContext } from 'formik' +import { Category } from 'model/Category' +import { Product } from 'model/Product' +import { useEffect, useState } from 'react' +import { Attributes } from './components' +import { ProductFormValues } from './interface' +import { ProductFormValidationSchema, mapProductToUserFormValues } from './utils' interface Props extends FlexProps { handleSubmit: (values: ProductFormValues, formikHelpers: FormikHelpers) => void @@ -21,26 +30,56 @@ interface Props extends FlexProps { } function ProductForm({ isNew }: { isNew: boolean }) { - const { values, errors, setFieldValue } = useFormikContext() + const [categories, setCategories] = useState() + + const { values, errors, setFieldValue, submitForm, isSubmitting, isValidating, isValid, dirty } = + useFormikContext() + + useEffect(() => { + getCategories().then(setCategories).catch(console.error) + }, []) const handleFirstNameChange = (field: string) => (e: React.ChangeEvent) => setFieldValue(field, e.target.value) const handleNumberChange = (field: string) => (value: string) => setFieldValue(field, value) + const handleCategoryChange = (e: React.ChangeEvent) => setFieldValue('categoryId', +e.target.value) + + const handleDescriptionChange = (e: React.ChangeEvent) => + setFieldValue('description', e.target.value) + + if (!categories) { + return ( + + + + ) + } + return ( <> - {isNew ? 'Create new product' : 'Update product'} + + {isNew ? 'Create new product' : 'Update product'} + + + {/* Product form fields */} - + Product name {!!errors.name && {errors.name}} - + SKU {!!errors.sku && {errors.sku}} @@ -49,7 +88,7 @@ function ProductForm({ isNew }: { isNew: boolean }) { - + Price @@ -57,7 +96,7 @@ function ProductForm({ isNew }: { isNew: boolean }) { {!!errors.price && {errors.price}} - + Quantity in stock @@ -68,6 +107,34 @@ function ProductForm({ isNew }: { isNew: boolean }) { + + + + + Category + + + + + + + + + + Description +