Skip to content

Commit

Permalink
Add product from autocomplete input (#98)
Browse files Browse the repository at this point in the history
* Init ProductAutocomplete component

* Refactor ProductAutocomplete

* Refactor ProductAutocomplete inner dialog

* Init ProductInputDialog

* Add and edit value on the fly

* Load products from API

* Allow temporary values that do not exist in options

* Added validation props

* Added caloriesCost and defaultQuantity to input form

* Added category to input form

* Fixed input validation

* Use different types for autocomplete option

* Added test for new ProductInputDialog

* ProductAutocomplete tests: select existing option case

* ProductAutocomplete tests: add/edit on the fly

* Refactored tests

* Use new ProductAutocomplete for add/edit note

* Create freeSolo product on note submit

* Show loading when freeSolo product is creating

* Clear previous freeSolo input

* Implemented ProductAutocomplete without dialog

* WIP: testing ProductAutocompleteWithoutDialog in AddNote

* WIP: use InputDialogStateType instead of productEditting toggle

* Refactor AddNote state and event handlers

* AddNote: added loading progress

* Fixed submit disabled validation for note

* Fixed submit disabled validation for product

* Added remaining fields

* Fixed loading progress flickering

* Added reusable NoteInputDialog for AddNote and EditNote

* NoteInputDialog children -> renderProp

* Removed async logic from NoteInputDialog

* Fixed tests

* Added created product Id to API response

* Fixed renderMode for NoteInputDialog

* Added tests new NoteInputDialog

* Split NoteInputDialog logic into hooks

* Disabled refetchOnFocus

* Make submit button text shorter

* Removed unused code

* Removed duplicate EMPTY_DIALOG_VALUE

* Renamed 'dialogValue' to 'formValues', added useFormValues hook

* Renamed 'productsModel' to 'productModel'

* Split useAutocomplete into two hooks

* Fixed eslint warning

* Move createProductIfNotExists to hook, refactor mappings
  • Loading branch information
pkirilin authored May 6, 2024
1 parent ba219d6 commit f47fbe9
Show file tree
Hide file tree
Showing 59 changed files with 1,605 additions and 531 deletions.
40 changes: 21 additions & 19 deletions src/backend/src/FoodDiary.API/Controllers/v1/ProductsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
using System.Threading.Tasks;
using AutoMapper;
using FoodDiary.API.Dtos;
using FoodDiary.Domain.Entities;
using Microsoft.AspNetCore.Mvc;
using FoodDiary.API.Requests;
using MediatR;
using FoodDiary.Application.Products.Requests;
using System.Linq;
using FoodDiary.API.Mapping;
using FoodDiary.Application.Products.Create;
using FoodDiary.Application.Services.Products;
using Microsoft.AspNetCore.Authorization;

Expand Down Expand Up @@ -66,31 +67,26 @@ public async Task<IActionResult> GetProducts([FromQuery] ProductsSearchRequest p

return Ok(searchResultDto);
}

/// <summary>
/// Creates new product if product with the same name doesn't exist
/// </summary>
/// <param name="productData">New product info</param>
/// <param name="cancellationToken"></param>

[HttpPost]
[ProducesResponseType((int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.BadRequest)]
public async Task<IActionResult> CreateProduct([FromBody] ProductCreateEditRequest productData, CancellationToken cancellationToken)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
var request = new CreateProductRequest(
productData.Name,
productData.CaloriesCost,
productData.DefaultQuantity,
productData.CategoryId);

var productsWithTheSameName = await _mediator.Send(new GetProductsByExactNameRequest(productData.Name), cancellationToken);

if (productsWithTheSameName.Any())
{
ModelState.AddModelError(nameof(productData.Name), $"Product with the name '{productData.Name}' already exists");
return BadRequest(ModelState);
}
var response = await _mediator.Send(request, cancellationToken);

var product = _mapper.Map<Product>(productData);
await _mediator.Send(new CreateProductRequest(product), cancellationToken);
return Ok();
return response switch
{
CreateProductResponse.ProductAlreadyExists => ProductAlreadyExists(productData),
CreateProductResponse.Success success => Ok(success.Product.ToCreateProductResponse()),
_ => Conflict()
};
}

/// <summary>
Expand Down Expand Up @@ -168,4 +164,10 @@ public async Task<IActionResult> GetProductsForAutocomplete(CancellationToken ca
var products = await _productsService.GetAutocompleteItemsAsync(cancellationToken);
return Ok(products);
}

private IActionResult ProductAlreadyExists(ProductCreateEditRequest product)
{
ModelState.AddModelError(nameof(product.Name), $"Product with the name '{product.Name}' already exists");
return BadRequest(ModelState);
}
}
3 changes: 3 additions & 0 deletions src/backend/src/FoodDiary.API/Mapping/ProductsMapper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using FoodDiary.API.Dtos;
using FoodDiary.Contracts.Products;
using FoodDiary.Domain.Entities;

namespace FoodDiary.API.Mapping;
Expand All @@ -14,4 +15,6 @@ public static class ProductsMapper
CategoryId = product.CategoryId,
CategoryName = product.Category.Name
};

public static CreateProductResponse ToCreateProductResponse(this Product product) => new(product.Id);
}
6 changes: 3 additions & 3 deletions src/backend/src/FoodDiary.API/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@
"MaxImportFileLengthBytes": 5242880
},
"App": {
"ForwardHttpsSchemeManuallyForAllRequests": true,
"ForwardHttpsSchemeManuallyForAllRequests": false,
"Logging": {
"WriteLogsInJsonFormat": true,
"UseYandexCloudLogsFormat": true
"WriteLogsInJsonFormat": false,
"UseYandexCloudLogsFormat": false
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Threading;
using System.Threading.Tasks;
using FoodDiary.Domain.Entities;
using FoodDiary.Domain.Repositories.v2;
using JetBrains.Annotations;
using MediatR;

namespace FoodDiary.Application.Products.Create;

public record CreateProductRequest(
string Name,
int CaloriesCost,
int DefaultQuantity,
int CategoryId) : IRequest<CreateProductResponse>;

public abstract record CreateProductResponse
{
public record ProductAlreadyExists : CreateProductResponse;

public record Success(Product Product) : CreateProductResponse;
}

[UsedImplicitly]
internal class CreateProductRequestHandler(
IProductsRepository repository) : IRequestHandler<CreateProductRequest, CreateProductResponse>
{
public async Task<CreateProductResponse> Handle(CreateProductRequest request, CancellationToken cancellationToken)
{
var productWithTheSameName = await repository.FindByExactName(request.Name, cancellationToken);

if (productWithTheSameName is not null)
{
return new CreateProductResponse.ProductAlreadyExists();
}

var product = new Product
{
Name = request.Name,
CaloriesCost = request.CaloriesCost,
DefaultQuantity = request.DefaultQuantity,
CategoryId = request.CategoryId
};

await repository.Create(product, cancellationToken);

return new CreateProductResponse.Success(product);
}
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,15 @@
using System.Threading;
using System.Threading.Tasks;
using FoodDiary.Contracts.Products;
using FoodDiary.Domain.Abstractions.v2;
using FoodDiary.Domain.Repositories.v2;

namespace FoodDiary.Application.Services.Products;

internal class ProductsService : IProductsService
internal class ProductsService(IProductsRepository repository) : IProductsService
{
private readonly IFoodDiaryUnitOfWork _unitOfWork;

public ProductsService(IFoodDiaryUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}

public async Task<ProductAutocompleteItemDto[]> GetAutocompleteItemsAsync(CancellationToken cancellationToken)
{
var products = await _unitOfWork.Products.GetAllOrderedByNameAsync(cancellationToken);
var products = await repository.GetAllOrderedByNameAsync(cancellationToken);

return products
.Select(p => p.ToProductAutocompleteItemDto())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using JetBrains.Annotations;

namespace FoodDiary.Contracts.Products;

[PublicAPI]
public record CreateProductResponse(int Id);
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,5 @@ namespace FoodDiary.Domain.Abstractions.v2;

public interface IFoodDiaryUnitOfWork
{
IProductsRepository Products { get; }

ICategoriesRepository Categories { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@
using System.Threading.Tasks;
using FoodDiary.Domain.Entities;

#nullable enable

namespace FoodDiary.Domain.Repositories.v2;

public interface IProductsRepository
{
Task<Product[]> GetAllOrderedByNameAsync(CancellationToken cancellationToken);

Task<Product?> FindByExactName(string name, CancellationToken cancellationToken);

Task Create(Product product, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ private static void AddDataAccess(this IServiceCollection services)

services.AddScoped<IFoodDiaryUnitOfWork, FoodDiaryUnitOfWork>();
services.AddScoped<IPagesRepository, PagesRepository>();
services.AddScoped<IProductsRepository, ProductsRepository>();
}

private static void AddIntegrations(this IServiceCollection services)
Expand Down
13 changes: 2 additions & 11 deletions src/backend/src/FoodDiary.Infrastructure/FoodDiaryUnitOfWork.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,7 @@

namespace FoodDiary.Infrastructure;

public class FoodDiaryUnitOfWork : IFoodDiaryUnitOfWork
public class FoodDiaryUnitOfWork(FoodDiaryContext context) : IFoodDiaryUnitOfWork
{
private readonly FoodDiaryContext _context;

public FoodDiaryUnitOfWork(FoodDiaryContext context)
{
_context = context;
}

public IProductsRepository Products => new ProductsRepository(_context.Products);

public ICategoriesRepository Categories => new CategoriesRepository(_context.Categories);
public ICategoriesRepository Categories => new CategoriesRepository(context.Categories);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,19 @@

namespace FoodDiary.Infrastructure.Repositories.v2;

internal class ProductsRepository : IProductsRepository
internal class ProductsRepository(FoodDiaryContext context) : IProductsRepository
{
private readonly DbSet<Product> _products;

public ProductsRepository(DbSet<Product> products)
{
_products = products;
}

public async Task<Product[]> GetAllOrderedByNameAsync(CancellationToken cancellationToken)
{
return await _products
public Task<Product[]> GetAllOrderedByNameAsync(CancellationToken cancellationToken) =>
context.Products
.OrderBy(p => p.Name)
.ToArrayAsync(cancellationToken);

public Task<Product> FindByExactName(string name, CancellationToken cancellationToken) =>
context.Products.FirstOrDefaultAsync(p => p.Name == name, cancellationToken);

public async Task Create(Product product, CancellationToken cancellationToken)
{
context.Products.Add(product);
await context.SaveChangesAsync(cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,12 @@ public Task Then_products_list_for_autocomplete_contains_items(params Product[]
return Task.CompletedTask;
}

public Task Then_product_is_successfully_created()
public async Task Then_product_is_successfully_created()
{
_createProductResponse.StatusCode.Should().Be(HttpStatusCode.OK);
return Task.CompletedTask;
var response = await _createProductResponse.Content.ReadFromJsonAsync<CreateProductResponse>();
response.Should().NotBeNull();
response!.Id.Should().BePositive();
}

public Task Then_product_is_successfully_updated()
Expand Down
1 change: 1 addition & 0 deletions src/frontend/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"prettier/prettier": 2,
"no-console": "warn",
"react/jsx-uses-react": "off",
"react/jsx-no-useless-fragment": "warn",
"react/react-in-jsx-scope": "off",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/src/entities/product/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './ui';
export * as productModel from './model';
8 changes: 8 additions & 0 deletions src/frontend/src/entities/product/model/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { type FormValues } from './types';

export const EMPTY_FORM_VALUES: FormValues = {
name: '',
defaultQuantity: 100,
caloriesCost: 100,
category: null,
};
5 changes: 5 additions & 0 deletions src/frontend/src/entities/product/model/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './types';
export * from './constants';
export * from './useAutocompleteData';
export * from './useAutocompleteInput';
export * from './useFormValues';
29 changes: 29 additions & 0 deletions src/frontend/src/entities/product/model/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { type SelectOption } from '@/types';

export interface FormValues {
name: string;
caloriesCost: number;
defaultQuantity: number;
category: SelectOption | null;
}

interface AutocompleteBaseOption {
freeSolo?: boolean;
name: string;
defaultQuantity: number;
}

export interface AutocompleteExistingOption extends AutocompleteBaseOption {
freeSolo?: false;
id: number;
}

export interface AutocompleteFreeSoloOption extends AutocompleteBaseOption {
freeSolo: true;
editing: boolean;
caloriesCost: number;
category: SelectOption | null;
inputValue?: string;
}

export type AutocompleteOptionType = AutocompleteExistingOption | AutocompleteFreeSoloOption;
Loading

0 comments on commit f47fbe9

Please sign in to comment.