Merge pull request '(FEAT): Recipe editing is complete!' (#87) from feature/edit-recipe into master
Some checks failed
Deploy application with Docker / build_and_deploy (push) Failing after 43s
Some checks failed
Deploy application with Docker / build_and_deploy (push) Failing after 43s
Reviewed-on: #87
This commit is contained in:
commit
1e88f075cb
@ -131,6 +131,37 @@ func (s *Server) CreateRecipeHandlerV2(ctx *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) EditRecipeHandlerV2(ctx *gin.Context) {
|
||||
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domainUser.User) {
|
||||
id := ctx.Param("id")
|
||||
parsedId, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||
"status": http.StatusBadRequest,
|
||||
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
recipe, err := s.deps.RecipeService.EditRecipe(ctx, parsedId, user.Id)
|
||||
|
||||
_, err = s.deps.EngagementService.UserEditRecipe(user.Id, recipe.Id)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||
"status": http.StatusBadRequest,
|
||||
"message": fmt.Sprintf("[ERROR] Failed to create recipe engagement. %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"status": http.StatusOK,
|
||||
"message": "[OK] Successfully updated recipe.",
|
||||
"recipe": recipe,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) DeleteRecipeHandlerV2(ctx *gin.Context) {
|
||||
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domainUser.User) {
|
||||
id := ctx.Param("id")
|
||||
|
||||
@ -259,10 +259,11 @@ func (s *Server) Setup() *Server {
|
||||
|
||||
// ---- VERSION 2 ROUTES ---- //
|
||||
router_api_v2 := router_v2.Group(domain.API)
|
||||
router_api_v2.POST("/recipe", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.CreateRecipeHandlerV2)
|
||||
router_api_v2.GET("/recipe/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeHandlerV2)
|
||||
router_api_v2.PUT("/recipe/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EditRecipeHandlerV2)
|
||||
router_api_v2.GET("/recipe/of-the-week", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeOfTheWeekHandlerV2)
|
||||
router_api_v2.POST("/recipe/search", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.SearchRecipeHandlerV2)
|
||||
router_api_v2.POST("/recipe", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.CreateRecipeHandlerV2)
|
||||
router_api_v2.DELETE("/recipe/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.DeleteRecipeHandlerV2)
|
||||
router_api_v2.GET("/recipe/:id/is-owner", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.IsRecipeOwnerV2)
|
||||
|
||||
|
||||
@ -150,6 +150,20 @@ func (s *EngagementService) UserDeleteRecipe(userId, recipeId int) (domain.Engag
|
||||
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementDeleted)
|
||||
}
|
||||
|
||||
// UserEditRecipe requires a user ID and a recipe ID to create an engagement record in the database.
|
||||
// A message will be generated using the recipe data and then used to add a make engagement to the
|
||||
// database.
|
||||
func (s *EngagementService) UserEditRecipe(userId, recipeId int) (domain.Engagement, error) {
|
||||
recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
|
||||
if err != nil {
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("Edited \"%s\"", recipe.Title)
|
||||
|
||||
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementEdited)
|
||||
}
|
||||
|
||||
// GetUserEngagement returns a list of the users most recent engagement entries. The number of records
|
||||
// is determined by the limit passed into this function. The results are sorted, newest-to-oldest.
|
||||
func (s *EngagementService) GetUserEngagement(userId, limit int) ([]domain.Engagement, error) {
|
||||
|
||||
@ -80,6 +80,44 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) {
|
||||
return &recipe, nil
|
||||
}
|
||||
|
||||
func (s *RecipeService) EditRecipe(ctx *gin.Context, recipeId, userId int) (*domain.Recipe, error) {
|
||||
var req domain.EditRecipeRequest
|
||||
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if recipeId != req.Id {
|
||||
return nil, fmt.Errorf("[ERROR] Mismatched recipe IDs provided. Given %d and %d.", recipeId, req.Id)
|
||||
}
|
||||
|
||||
recipe := domain.Recipe{
|
||||
Id: recipeId,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Instructions: req.Instructions,
|
||||
Serves: req.Serves,
|
||||
Difficulty: req.Difficulty,
|
||||
Duration: req.Duration,
|
||||
Category: req.Category,
|
||||
Ingredients: req.Ingredients,
|
||||
Sections: req.Sections,
|
||||
}
|
||||
|
||||
if err := s.recipeRepository.EditRecipe(&recipe, userId); err != nil {
|
||||
return &recipe, err
|
||||
}
|
||||
|
||||
// Update the tags
|
||||
if len(req.Tags) > 0 {
|
||||
if err := s.recipeRepository.UpdateRecipeTags(recipe, req.Tags); err != nil {
|
||||
return &recipe, fmt.Errorf("Failed to attach/create tags. %s\n", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return &recipe, nil
|
||||
}
|
||||
|
||||
// DeleteRecipe deletes a recipe in the database using the recipe repository. This function requires
|
||||
// the userId of the requesting user - to ensure the user is the owner of the recipe. Any errors will
|
||||
// be returned to caller when/if they occur.
|
||||
|
||||
@ -16,6 +16,7 @@ const (
|
||||
EngagementRated EngagementType = "rated"
|
||||
EngagementCreated EngagementType = "created"
|
||||
EngagementDeleted EngagementType = "deleted"
|
||||
EngagementEdited EngagementType = "edited"
|
||||
)
|
||||
|
||||
// Engagement is the database model of a user engagement. There is no need to map to a different
|
||||
|
||||
@ -9,5 +9,6 @@ type EngagementService interface {
|
||||
UserShareRecipe(userId, recipeId int) (Engagement, error)
|
||||
UserCreateRecipe(userId, recipeId int) (Engagement, error)
|
||||
UserDeleteRecipe(userId, recipeId int) (Engagement, error)
|
||||
UserEditRecipe(userId, recipeId int) (Engagement, error)
|
||||
GetUserEngagement(userId, limit int) ([]Engagement, error)
|
||||
}
|
||||
|
||||
@ -156,7 +156,7 @@ type RecipeTag struct {
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
// TODO: Comment
|
||||
// TODO: Document
|
||||
type CreateRecipeRequest struct {
|
||||
Title string
|
||||
Description string
|
||||
@ -169,3 +169,18 @@ type CreateRecipeRequest struct {
|
||||
Sections []RecipeIngredientSection
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// TODO Document
|
||||
type EditRecipeRequest struct {
|
||||
Id int
|
||||
Title string
|
||||
Description string
|
||||
Instructions []RecipeInstruction
|
||||
Serves int
|
||||
Difficulty int
|
||||
Duration RecipeDuration
|
||||
Category RecipeMeal
|
||||
Ingredients []RecipeIngredient
|
||||
Sections []RecipeIngredientSection
|
||||
Tags []string
|
||||
}
|
||||
|
||||
@ -2,11 +2,13 @@ package domain
|
||||
|
||||
type RecipeRepository interface {
|
||||
CreateRecipe(recipe *Recipe) error
|
||||
EditRecipe(recipe *Recipe, userId int) error
|
||||
DeleteRecipe(recipeId int) error
|
||||
GetRecipe(id int, userId *int) (*Recipe, error)
|
||||
GetRecipes(ids []int, userId *int) ([]Recipe, error)
|
||||
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]int, error)
|
||||
CreateRecipeTags(recipe Recipe, tags []string) error
|
||||
UpdateRecipeTags(recipe Recipe, tags []string) error
|
||||
GetUserRecipesIds(userId int) ([]int, error)
|
||||
GetUserFavoriteRecipesIds(userId int) ([]int, error)
|
||||
GetRecipeTags(recipe *Recipe) error
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
|
||||
type RecipeService interface {
|
||||
CreateRecipe(ctx *gin.Context) (*Recipe, error)
|
||||
EditRecipe(ctx *gin.Context, recipeId, userId int) (*Recipe, error)
|
||||
DeleteRecipe(userId, recipeId int) error
|
||||
GetRecipe(id int, userId *int) (*Recipe, error)
|
||||
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]Recipe, error)
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
|
||||
-- Desc: Updated the E_ENGAGEMENT enum to contain edited.
|
||||
-- Date: 02/01/2026
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TYPE E_ENGAGEMENT
|
||||
ADD VALUE IF NOT EXISTS 'edited'; -- edited recipe
|
||||
|
||||
COMMIT;
|
||||
@ -14,4 +14,5 @@ psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infra
|
||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/012_update_recipes_table_deleted_column.sql
|
||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/013_update_recipes_allow_large_servings.sql
|
||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/014_create_logs_table.sql
|
||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/015_update_engagement_enum.sql
|
||||
|
||||
|
||||
@ -31,7 +31,6 @@ func NewEngagementRepository(db *sql.DB) domain.EngagementRepository {
|
||||
func (r *EngagementRepository) AddUserEngagement(userId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
@ -58,7 +57,6 @@ func (r *EngagementRepository) AddUserEngagement(userId int, message string, eng
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
tx.Rollback()
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
@ -80,7 +78,6 @@ func (r *EngagementRepository) AddUserEngagement(userId int, message string, eng
|
||||
func (r *EngagementRepository) AddUserEntityEngagement(userId, entityId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
@ -107,7 +104,6 @@ func (r *EngagementRepository) AddUserEntityEngagement(userId, entityId int, mes
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
tx.Rollback()
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
@ -141,7 +137,6 @@ func (r *EngagementRepository) AddEngagement(message string, engagementType doma
|
||||
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
@ -168,7 +163,6 @@ func (r *EngagementRepository) AddEngagement(message string, engagementType doma
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
tx.Rollback()
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
@ -202,7 +196,6 @@ func (r *EngagementRepository) AddEntityEngagement(entityId int, message string,
|
||||
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
@ -229,7 +222,6 @@ func (r *EngagementRepository) AddEntityEngagement(entityId int, message string,
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
tx.Rollback()
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
@ -339,7 +331,6 @@ func (r *EngagementRepository) GetUserEngagementFiltered(userId, limit int, enga
|
||||
func (r *EngagementRepository) UserFavoriteRecipeToggle(userId, recipeId int) (bool, error) {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return false, err
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
domain "github.com/haydenhargreaves/Potion/internal/domain/recipe"
|
||||
"github.com/lib/pq"
|
||||
@ -33,7 +34,6 @@ func NewRecipeRepository(db *sql.DB) domain.RecipeRepository {
|
||||
func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
@ -92,7 +92,6 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
@ -102,6 +101,97 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// EditRecipe updates a recipe in the database. The recipe provided must contain an ID, otherwise this
|
||||
// function will fail - it will not know what recipe to edit.
|
||||
func (r *RecipeRepository) EditRecipe(recipe *domain.Recipe, userId int) error {
|
||||
if recipe.Id <= 0 {
|
||||
return fmt.Errorf("[ERROR] Recipe must contain an ID. Cannot edit unknown recipe.")
|
||||
}
|
||||
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// This query will ensure the userId matches the owner.
|
||||
query := `UPDATE recipes SET
|
||||
title = $1,
|
||||
description = $2,
|
||||
instructions = $3,
|
||||
serves = $4,
|
||||
difficulty = $5,
|
||||
duration = $6,
|
||||
category = $7,
|
||||
ingredients = $8,
|
||||
modified = $9
|
||||
WHERE id = $10
|
||||
AND userid = $11;`
|
||||
|
||||
// NOTE: Data steps
|
||||
// cast duration to JSON
|
||||
// convert ingredients to store type
|
||||
// cast store type to JSON
|
||||
// extract string instructions from type
|
||||
// cast category to string
|
||||
// use nil for the modified time
|
||||
|
||||
durationJSON, err := json.Marshal(recipe.Duration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ingredientsStore := domain.RecipeIngredientStore{
|
||||
Sections: recipe.Sections,
|
||||
Ingredients: recipe.Ingredients,
|
||||
}
|
||||
|
||||
ingredientsJSON, err := json.Marshal(ingredientsStore)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
instructions := make([]string, len(recipe.Instructions))
|
||||
for i, instruction := range recipe.Instructions {
|
||||
instructions[i] = instruction.Content
|
||||
}
|
||||
|
||||
result, err := tx.Exec(
|
||||
query,
|
||||
recipe.Title,
|
||||
recipe.Description,
|
||||
pq.Array(instructions),
|
||||
recipe.Serves,
|
||||
recipe.Difficulty,
|
||||
durationJSON,
|
||||
string(recipe.Category),
|
||||
ingredientsJSON,
|
||||
time.Now().UTC(),
|
||||
recipe.Id,
|
||||
userId,
|
||||
)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if rows != 1 {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("[ERROR] Modified an unexpected number of rows. Expected 1, modified %d.", rows)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRecipe deletes a recipe in the database. This is done by setting the deleted field to true.
|
||||
// This will create a "soft delete" effect. This function does not validate that the user is the owner,
|
||||
// so the caller should validate the owner. If any errors occur, they will be returned to the caller.
|
||||
@ -437,7 +527,6 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *i
|
||||
func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string) error {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
@ -487,6 +576,82 @@ func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateRecipeTags replaces all existing tags for a recipe with a new list of tags.
|
||||
// It removes all current tag associations, creates any new tags that don't exist,
|
||||
// and creates new associations for the provided tags. The recipe object must contain
|
||||
// a valid ID. Any errors will be bubbled to the caller.
|
||||
func (r *RecipeRepository) UpdateRecipeTags(recipe domain.Recipe, tags []string) error {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback() // Rollback if we don't commit
|
||||
|
||||
if recipe.Id <= 0 {
|
||||
return fmt.Errorf("[ERROR] Recipe must have a valid ID")
|
||||
}
|
||||
|
||||
// Step 1: Delete all existing tag associations for this recipe
|
||||
deleteQuery := `DELETE FROM RecipeTags WHERE RecipeId = $1;`
|
||||
if _, err := tx.Exec(deleteQuery, recipe.Id); err != nil {
|
||||
return fmt.Errorf("[ERROR] Failed to delete existing recipe tags: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Normalize the tag names (lower case with trimmed space)
|
||||
normalized := make(map[string]struct{}) // Use map to disallow duplicates
|
||||
for _, tag := range tags {
|
||||
trimmed := strings.ToLower(strings.TrimSpace(tag))
|
||||
if trimmed != "" {
|
||||
normalized[trimmed] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// If no tags provided, we're done (all tags removed)
|
||||
if len(normalized) == 0 {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 3: Insert the tags into the DB and return their IDs into the tag ID list
|
||||
var tagIds []int
|
||||
for tag := range normalized {
|
||||
var tagId int
|
||||
query := `
|
||||
INSERT INTO tags (name) VALUES ($1)
|
||||
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id;
|
||||
`
|
||||
err := tx.QueryRow(query, tag).Scan(&tagId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[ERROR] Failed to retrieve or create tag: %w", err)
|
||||
}
|
||||
tagIds = append(tagIds, tagId)
|
||||
}
|
||||
|
||||
// Step 4: Insert the new tag associations
|
||||
// Use a single prepared statement for all inserts
|
||||
stmt, err := tx.Prepare("INSERT INTO RecipeTags (RecipeId, TagId) VALUES ($1, $2);")
|
||||
if err != nil {
|
||||
return fmt.Errorf("[ERROR] Failed to create statement for recipe tag mapping: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, id := range tagIds {
|
||||
if _, err := stmt.Exec(recipe.Id, id); err != nil {
|
||||
return fmt.Errorf("[ERROR] Failed to insert tag-recipe mapping: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Commit the transaction
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserRecipes gets a list of a users owned recipes. This function does not ensure the user is
|
||||
// authenticated or exists. If nothing is found, a blank slice will be returned. The resulting list
|
||||
// is sorted by the created dates, newest first. Any errors will be bubbled to the caller.
|
||||
|
||||
@ -32,7 +32,6 @@ func NewUserRepository(db *sql.DB) domain.UserRepository {
|
||||
func (r *UserRepository) CreateGoogleUser(googleUserInfo *domain.GoogleUserInfo, googleRefreshToken string) (domain.User, error) {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return domain.User{}, err
|
||||
}
|
||||
|
||||
@ -66,7 +65,6 @@ func (r *UserRepository) CreateGoogleUser(googleUserInfo *domain.GoogleUserInfo,
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
tx.Rollback()
|
||||
return domain.User{}, err
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ interface DeleteButtonProps {
|
||||
|
||||
export default function DeleteButton({ clickHandler }: DeleteButtonProps) {
|
||||
return (
|
||||
<button onClick={clickHandler} className="flex items-center justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-red-300 duration-300 cursor-pointer">
|
||||
<button onClick={clickHandler} className="flex items-center min-w-1/4 justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-red-300 duration-300 cursor-pointer">
|
||||
<DeleteIconSmall />
|
||||
Delete
|
||||
</button>
|
||||
|
||||
15
web/src/components/buttons/EditButton.tsx
Normal file
15
web/src/components/buttons/EditButton.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import EditIconSmall from "../icons/EditIconSmall";
|
||||
|
||||
interface EditButtonProps {
|
||||
clickHandler: () => void
|
||||
}
|
||||
|
||||
export default function EditButton({ clickHandler }: EditButtonProps) {
|
||||
return (
|
||||
<button onClick={clickHandler} className="flex items-center min-w-1/4hh justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer">
|
||||
<EditIconSmall />
|
||||
Edit
|
||||
</button>
|
||||
);
|
||||
|
||||
}
|
||||
@ -32,7 +32,7 @@ export default function FavoriteButton({ favorite, id }: FavoriteButtonProps) {
|
||||
const result = await EngagementFavoriteRecipe(id);
|
||||
if (isApiError(result)) {
|
||||
console.error(result.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@ -43,7 +43,7 @@ export default function FavoriteButton({ favorite, id }: FavoriteButtonProps) {
|
||||
|
||||
return _favorite ? (
|
||||
<button
|
||||
className="flex items-center justify-center gap-x-1 rounded-lg border border-blue-300 bg-blue-50 text-gray-800 px-6 py-3 flex-grow hover:bg-blue-100 hover:border-blue-500 duration-300 cursor-pointer"
|
||||
className="flex items-center min-w-1/4 justify-center gap-x-1 rounded-lg border border-blue-300 bg-blue-50 text-gray-800 px-6 py-3 flex-grow hover:bg-blue-100 hover:border-blue-500 duration-300 cursor-pointer"
|
||||
onClick={() => void clickHandler()}
|
||||
>
|
||||
<svg className="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@ -30,12 +30,12 @@ export default function MadeButton({ id }: MadeButtonProps) {
|
||||
const result = await EngagementMakeRecipe(id);
|
||||
if (isApiError(result)) {
|
||||
console.error(result.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`flex items-center justify-center gap-x-1 rounded-lg border ${clicked ? "border-blue-500 text-blue-500" : "border-gray-300 text-gray-800"} px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer`}
|
||||
className={`flex items-center min-w-1/4 justify-center gap-x-1 rounded-lg border ${clicked ? "border-blue-500 text-blue-500" : "border-gray-300 text-gray-800"} px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer`}
|
||||
onClick={() => void clickHandler()}
|
||||
>
|
||||
<svg
|
||||
|
||||
@ -29,7 +29,7 @@ export default function ShareButton({ id }: ShareButtonProps) {
|
||||
};
|
||||
|
||||
return clicked ? (
|
||||
<button className="flex items-center justify-center gap-x-1 rounded-lg border border-green-500 text-green-500 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300">
|
||||
<button className="flex items-center min-w-1/4 justify-center gap-x-1 rounded-lg border border-green-500 text-green-500 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300">
|
||||
<svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
@ -41,7 +41,7 @@ export default function ShareButton({ id }: ShareButtonProps) {
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="flex items-center justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer"
|
||||
className="flex items-center min-w-1/4 justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer"
|
||||
onClick={() => void clickHandler()}
|
||||
>
|
||||
<svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Reorder } from "motion/react";
|
||||
import InstructionElement from "./InstructionElement";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useEffect, type Dispatch, type SetStateAction } from "react";
|
||||
import type { RecipeInstruction } from "../../types/recipe";
|
||||
import type { RecipeValidationEntry } from "../../pages/Create";
|
||||
|
||||
|
||||
7
web/src/components/icons/EditIconSmall.tsx
Normal file
7
web/src/components/icons/EditIconSmall.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export default function EditIconSmall() {
|
||||
return (
|
||||
<svg className="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<path d="M535.6 85.7C513.7 63.8 478.3 63.8 456.4 85.7L432 110.1L529.9 208L554.3 183.6C576.2 161.7 576.2 126.3 554.3 104.4L535.6 85.7zM236.4 305.7C230.3 311.8 225.6 319.3 222.9 327.6L193.3 416.4C190.4 425 192.7 434.5 199.1 441C205.5 447.5 215 449.7 223.7 446.8L312.5 417.2C320.7 414.5 328.2 409.8 334.4 403.7L496 241.9L398.1 144L236.4 305.7zM160 128C107 128 64 171 64 224L64 480C64 533 107 576 160 576L416 576C469 576 512 533 512 480L512 384C512 366.3 497.7 352 480 352C462.3 352 448 366.3 448 384L448 480C448 497.7 433.7 512 416 512L160 512C142.3 512 128 497.7 128 480L128 224C128 206.3 142.3 192 160 192L256 192C273.7 192 288 177.7 288 160C288 142.3 273.7 128 256 128L160 128z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -46,7 +46,7 @@ export function useIngredients() {
|
||||
const ingredientChange = (id: string, name: "Amount" | "Unit" | "Name", value: string) => {
|
||||
setIngredients(prev =>
|
||||
prev.map(ing =>
|
||||
ing.Id === id
|
||||
ing.Id === id
|
||||
? { ...ing, [name]: name === "Amount" ? Number(value) : value }
|
||||
: ing
|
||||
)
|
||||
@ -72,6 +72,7 @@ export function useIngredients() {
|
||||
return {
|
||||
sections,
|
||||
ingredients,
|
||||
setIngredients,
|
||||
setSections,
|
||||
sectionChange,
|
||||
ingredientChange,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Banner from "../components/Banner";
|
||||
import { isRecipeMeal, type RecipeInstruction } from "../types/recipe";
|
||||
import { isRecipeMeal, type Recipe, type RecipeInstruction } from "../types/recipe";
|
||||
import InstructionList from "../components/forms/InstructionList";
|
||||
import ValidationErrorList from "../components/forms/ValidationErrorList";
|
||||
import IngredientSection from "../components/forms/IngredientSection";
|
||||
@ -13,10 +13,10 @@ import RecipeCreateFormWrapper from "../components/inputs/RecipeCreateFormWrappe
|
||||
import RecipeCreateFormTagsInputs from "../components/inputs/RecipeCreateFormTagsInput";
|
||||
import { useIngredients } from "../hooks/useIngredients";
|
||||
import { validateCreateRecipeForm } from "../hooks/validation";
|
||||
import { CreateRecipe } from "../services/RecipeService";
|
||||
import type { CreateRecipeRequest } from "../types/api/recipe";
|
||||
import { isApiError } from "../types/api/error";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateRecipe, EditRecipe, GetRecipe, IsRecipeOwner } from "../services/RecipeService";
|
||||
import type { CreateRecipeRequest, EditRecipeRequest } from "../types/api/recipe";
|
||||
import { isApiError, type ApiError } from "../types/api/error";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import ROUTE_CONSTANTS from "../types/routes";
|
||||
|
||||
// TODO: Move these
|
||||
@ -128,6 +128,7 @@ export default function Create() {
|
||||
// Functions
|
||||
const createRecipe = async (): Promise<void> => {
|
||||
// Exit if not valid recipe meal
|
||||
// This is a REQUIRED typescript check.
|
||||
if (!isRecipeMeal(category)) {
|
||||
console.error("[ERROR] Recipe meal is invalid.");
|
||||
return;
|
||||
@ -160,10 +161,53 @@ export default function Create() {
|
||||
await navigate(ROUTE_CONSTANTS.Recipe(response.Id));
|
||||
};
|
||||
|
||||
const editRecipe = async (): Promise<void> => {
|
||||
const recipeId = Number(editingId);
|
||||
|
||||
if (!recipeId) {
|
||||
console.error("[ERROR] Invalid reicpe ID");
|
||||
return;
|
||||
}
|
||||
|
||||
// Exit if not valid recipe meal
|
||||
// This is a REQUIRED typescript check.
|
||||
if (!isRecipeMeal(category)) {
|
||||
console.error("[ERROR] Recipe meal is invalid.");
|
||||
return;
|
||||
}
|
||||
|
||||
const recipe: EditRecipeRequest = {
|
||||
Id: recipeId,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Instructions: instructions,
|
||||
Serves: Number(servingSize),
|
||||
Difficulty: Number(difficulty),
|
||||
Duration: {
|
||||
Prep: Number(prepTime),
|
||||
Cook: Number(cookTime),
|
||||
Total: Number(prepTime) + Number(cookTime)
|
||||
},
|
||||
Category: category,
|
||||
Ingredients: ingredients,
|
||||
Sections: sections,
|
||||
Tags: tags,
|
||||
};
|
||||
|
||||
const response = await EditRecipe(recipe);
|
||||
if (isApiError(response)) {
|
||||
console.error(response);
|
||||
return;
|
||||
}
|
||||
// TODO: Success toast!
|
||||
await navigate(ROUTE_CONSTANTS.Recipe(response.Id));
|
||||
}
|
||||
|
||||
// Import ingredients
|
||||
const {
|
||||
sections,
|
||||
ingredients,
|
||||
setIngredients,
|
||||
setSections,
|
||||
sectionChange,
|
||||
ingredientChange,
|
||||
@ -244,7 +288,11 @@ export default function Create() {
|
||||
return;
|
||||
}
|
||||
|
||||
void createRecipe();
|
||||
if (editingId) {
|
||||
void editRecipe();
|
||||
} else {
|
||||
void createRecipe();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -267,26 +315,115 @@ export default function Create() {
|
||||
setIsFormValid(bools_valid && ingredients_valid && instructions_valid);
|
||||
}, [validation, dirty]);
|
||||
|
||||
useEffect(() => {
|
||||
console.debug("@validation", validation);
|
||||
}, [validation]);
|
||||
// EDITING IMPLEMENTATION
|
||||
const [searchParams] = useSearchParams();
|
||||
const editingId = searchParams.get("edit");
|
||||
|
||||
// Functions
|
||||
const getIsAuthor = async (recipeId: number) => {
|
||||
if (!recipeId) return;
|
||||
const response = await IsRecipeOwner(recipeId);
|
||||
if (isApiError(response)) {
|
||||
console.error(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
const setForm = (recipe: Recipe) => {
|
||||
setTitle(recipe.Title);
|
||||
setDescription(recipe.Description);
|
||||
setTags(recipe.Tags.map(tag => tag.Name));
|
||||
setPrepTime(recipe.Duration.Prep.toString());
|
||||
setCookTime(recipe.Duration.Cook.toString());
|
||||
setServingSize(recipe.Serves.toString());
|
||||
setCategory(recipe.Category);
|
||||
setDifficulty(recipe.Difficulty.toString());
|
||||
|
||||
// Generate IDs for instructions and store them
|
||||
const instructionsWithIds: RecipeInstruction[] = recipe.Instructions.map(ins => ({
|
||||
Id: crypto.randomUUID(),
|
||||
Content: ins.Content
|
||||
}));
|
||||
setInstructions(instructionsWithIds);
|
||||
|
||||
setSections(recipe.Sections);
|
||||
|
||||
// Manually set the local state, not ideal but it works
|
||||
setIngredients(recipe.Ingredients.sort((a, b) => a.SectionId.localeCompare(b.SectionId)));
|
||||
|
||||
const ingredientsDirty: Record<string, boolean> = {};
|
||||
recipe.Ingredients.forEach(ing => {
|
||||
ingredientsDirty[ing.Id] = true;
|
||||
});
|
||||
|
||||
const instructionsDirty: Record<string, boolean> = {};
|
||||
instructionsWithIds.forEach(ins => {
|
||||
instructionsDirty[ins.Id] = true;
|
||||
});
|
||||
|
||||
setDirty({
|
||||
title: true,
|
||||
description: true,
|
||||
prepTime: true,
|
||||
cookTime: true,
|
||||
servingSize: true,
|
||||
category: true,
|
||||
difficulty: true,
|
||||
ingredients: ingredientsDirty,
|
||||
instructions: instructionsDirty,
|
||||
});
|
||||
};
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
console.debug("@dirty", dirty);
|
||||
}, [dirty]);
|
||||
const id = Number(editingId);
|
||||
if (!id) return;
|
||||
|
||||
const execute = async () => {
|
||||
const isAuthor = await getIsAuthor(id);
|
||||
|
||||
if (!isAuthor) {
|
||||
console.error("User is not the owner, and cannot edit this recipe.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result: Recipe | ApiError = await GetRecipe(id);
|
||||
if (isApiError(result)) {
|
||||
console.error(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setForm(result);
|
||||
};
|
||||
|
||||
void execute();
|
||||
}, [editingId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Banner content="Create Your Masterpiece" />
|
||||
<Banner content={editingId ? "Edit Your Recipe" : "Create Your Masterpiece"} />
|
||||
<div className="mx-4 md:mx-16 my-8">
|
||||
<p className="mb-8">
|
||||
Welcome to the Recipe Creation Wizard! Simply fill in the details about your culinary creation,
|
||||
including the recipe's name, a description, and other specifics like its category, duration,
|
||||
and difficulty. Don't forget to dynamically add all your ingredients and instructions using
|
||||
the dedicated buttons, and feel free to upload an appealing image. All required fields are
|
||||
marked with an <span className="text-red-500">*</span>. Once everything looks perfect, just hit the "Create Recipe"
|
||||
button to
|
||||
share your masterpiece!
|
||||
{editingId ? (
|
||||
<>
|
||||
Welcome back! Update your recipe by modifying any of the details below, including the recipe's name,
|
||||
description, category, duration, difficulty, ingredients, and instructions. You can add or remove
|
||||
ingredients and instruction steps using the dedicated buttons, and update the recipe image if desired.
|
||||
All required fields are marked with an <span className="text-red-500">*</span>. Once you're happy
|
||||
with your changes, hit the "Update Recipe" button to save your edits!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Welcome to the Recipe Creation Wizard! Simply fill in the details about your culinary creation,
|
||||
including the recipe's name, a description, and other specifics like its category, duration,
|
||||
and difficulty. Don't forget to dynamically add all your ingredients and instructions using
|
||||
the dedicated buttons, and feel free to upload an appealing image. All required fields are
|
||||
marked with an <span className="text-red-500">*</span>. Once everything looks perfect, just
|
||||
hit the "Create Recipe" button to share your masterpiece!
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div>
|
||||
{/* Title Input */}
|
||||
@ -521,7 +658,7 @@ export default function Create() {
|
||||
disabled={!isFormValid}
|
||||
className={`${isFormValid ? "bg-gradient-to-r from-blue-200 to-purple-200 cursor-pointer" : "bg-gray-200 text-gray-500 cursor-not-allowed"} w-full py-2 rounded-lg text-lg shadow-md`}
|
||||
>
|
||||
Create Recipe
|
||||
{editingId ? "Save Changes" : "Create Recipe"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -18,6 +18,7 @@ import type { User } from "../types/user";
|
||||
import DeleteButton from "../components/buttons/DeleteButton";
|
||||
import ROUTE_CONSTANTS from "../types/routes";
|
||||
import ConfirmRecipeDeleteModal from "../components/modals/ConfirmRecipeDeleteModal";
|
||||
import EditButton from "../components/buttons/EditButton";
|
||||
|
||||
export default function RecipePage() {
|
||||
// Url params
|
||||
@ -54,7 +55,7 @@ export default function RecipePage() {
|
||||
|
||||
const getIsAuthor = async () => {
|
||||
if (!recipe) return;
|
||||
const response = await IsRecipeOwner(recipe.Id)
|
||||
const response = await IsRecipeOwner(recipe.Id);
|
||||
if (isApiError(response)) {
|
||||
setError(response.message);
|
||||
return;
|
||||
@ -80,6 +81,12 @@ export default function RecipePage() {
|
||||
setIsDeleting(true);
|
||||
}
|
||||
|
||||
const editHandler = () => {
|
||||
if (!recipe || !isAuthor) return;
|
||||
const route = ROUTE_CONSTANTS.Edit(Number(recipe.Id));
|
||||
void navigate(route);
|
||||
}
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
void getRecipe(Number(id));
|
||||
@ -102,9 +109,9 @@ export default function RecipePage() {
|
||||
|
||||
return recipe ? (
|
||||
<>
|
||||
{isDeleting &&
|
||||
<ConfirmRecipeDeleteModal
|
||||
cancelHandler={() => setIsDeleting(false)}
|
||||
{isDeleting &&
|
||||
<ConfirmRecipeDeleteModal
|
||||
cancelHandler={() => setIsDeleting(false)}
|
||||
deleteHandler={() => void deleteRecipe(recipe.Id)}
|
||||
/>}
|
||||
|
||||
@ -121,6 +128,7 @@ export default function RecipePage() {
|
||||
<ShareButton id={recipe.Id} />
|
||||
|
||||
{isAuthor && <DeleteButton clickHandler={deleteHandler} />}
|
||||
{isAuthor && <EditButton clickHandler={editHandler} />}
|
||||
</section>
|
||||
<div className="px-4 py-8 md:px-8">
|
||||
<h3 className="text-xl text-gray-800 font-semibold mb-2">About this recipe</h3>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import axios from "axios";
|
||||
import type { CreateRecipeRequest, CreateRecipeResponse, DeleteRecipeResponse, GetRecipeOfTheWeekResponse, GetRecipeResponse, IsRecipeOwnerResponse, SearchRecipesResponse } from "../types/api/recipe";
|
||||
import type { CreateRecipeRequest, CreateRecipeResponse, DeleteRecipeResponse, EditRecipeRequest, GetRecipeOfTheWeekResponse, GetRecipeResponse, IsRecipeOwnerResponse, SearchRecipesResponse, EditRecipeResponse } from "../types/api/recipe";
|
||||
import type { Recipe } from "../types/recipe";
|
||||
import type { ApiError } from "../types/api/error";
|
||||
import type { SearchFilters } from "../types/search";
|
||||
@ -64,6 +64,20 @@ export async function CreateRecipe(data: CreateRecipeRequest): Promise<Recipe |
|
||||
return response.data.recipe;
|
||||
}
|
||||
|
||||
export async function EditRecipe(data: EditRecipeRequest): Promise<Recipe | ApiError> {
|
||||
const response = await axios.put<EditRecipeResponse>(`${BACKEND_URL}/v2/api/recipe/${data.Id}`, data);
|
||||
|
||||
if (response.status !== 200 || response.data.recipe === undefined) {
|
||||
const err: ApiError = {
|
||||
status: response.data.status,
|
||||
message: response.data.message
|
||||
};
|
||||
return err;
|
||||
}
|
||||
|
||||
return response.data.recipe;
|
||||
}
|
||||
|
||||
export async function DeleteRecipe(id: number): Promise<ApiError | null> {
|
||||
const response = await axios.delete<DeleteRecipeResponse>(`${BACKEND_URL}/v2/api/recipe/${id}`);
|
||||
|
||||
|
||||
@ -24,6 +24,12 @@ export interface CreateRecipeResponse {
|
||||
recipe?: Recipe;
|
||||
}
|
||||
|
||||
export interface EditRecipeResponse {
|
||||
status: number;
|
||||
message: string;
|
||||
recipe?: Recipe;
|
||||
}
|
||||
|
||||
export interface CreateRecipeRequest {
|
||||
Title: string;
|
||||
Description: string;
|
||||
@ -37,6 +43,20 @@ export interface CreateRecipeRequest {
|
||||
Tags: string[];
|
||||
}
|
||||
|
||||
export interface EditRecipeRequest {
|
||||
Id: number;
|
||||
Title: string;
|
||||
Description: string;
|
||||
Instructions: RecipeInstruction[];
|
||||
Serves: number;
|
||||
Difficulty: number;
|
||||
Duration: RecipeDuration;
|
||||
Category: RecipeMeal;
|
||||
Ingredients: RecipeIngredient[];
|
||||
Sections: RecipeIngredientSection[];
|
||||
Tags: string[];
|
||||
}
|
||||
|
||||
export interface DeleteRecipeResponse {
|
||||
status: number;
|
||||
message: string;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
|
||||
export type EngagementType = "made" | "liked" | "viewed" | "shared" | "reviewed" | "rated";
|
||||
export type EngagementType = "made" | "liked" | "viewed" | "shared" | "reviewed" | "rated" | "created" | "deleted" | "edited";
|
||||
|
||||
export interface Engagement {
|
||||
Id: number;
|
||||
|
||||
@ -10,6 +10,7 @@ const ROUTE_CONSTANTS: {
|
||||
History: string;
|
||||
Search: string;
|
||||
Recipe: (id: number) => string;
|
||||
Edit: (id: number) => string;
|
||||
} = {
|
||||
Home: `${VERSION_FLAG}/web/home`,
|
||||
Favorites: `${VERSION_FLAG}/web/favorites`,
|
||||
@ -20,6 +21,7 @@ const ROUTE_CONSTANTS: {
|
||||
History: `${VERSION_FLAG}/web/history`,
|
||||
Search: `${VERSION_FLAG}/web/search`,
|
||||
Recipe: (id: number) => `${VERSION_FLAG}/web/recipe/${id}`,
|
||||
Edit: (id: number) => `${VERSION_FLAG}/web/create?edit=${id}`,
|
||||
};
|
||||
|
||||
export default ROUTE_CONSTANTS;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user