I'm having difficulty reusing my wizard form so that it can both create new recipes and edit existing ones.
- I cannot use model binding in Livewire 3.
- I can use 'legacy_model_binding' => true, but this is a workaround and not a sustainable - option.
- Form objects are not very practical when I need to reuse the form for creating and updating a recipe in one component (because of
unique
validation rule). - For now, the most obvious and simple option is to create two forms, one for creating and one for editing a recipe. Each form will use form objects to reduce the amount of code.
I'd also like to point out that my form is composed of 3 parts, with dynamic form fields logic implemented in steps 2 and 3. The store method also includes logic for creating an ingredient if it doesn't already exist in the database.
Does anyone have any ideas about this? (sorry for long code)
Code
class RecipeWizard extends Component
{
use WithFileUploads;
public $form_step = 1;
public $dishCategories;
public $cuisines;
public $menus;
public $units;
// Fields Step 1
#[Validate]
#[Rule(['nullable','mimes:jpeg,png,webp'])]
public $recipe_image;
public $recipe_name;
public $recipe_description;
public $recipe_category;
public $recipe_cuisine;
public $recipe_menu;
public $recipe_time;
public $recipe_servings;
// Fields Step 2
public $ingredients = [
[]
];
public $ingredient = ['ingredient_name' => null, 'ingredient_quantity' => null, 'ingredient_unit' => null];
// Fields Step 3
public $guide_steps = [
['step_image' => null, 'step_text' => null],
];
public $guide_step = ['step_image' => null, 'step_text' => null];
public function mount()
{
$this->dishCategories = DishCategory::get();
$this->cuisines = Cuisine::get();
$this->menus = Menu::get();
$this->units = Unit::get();
}
public function next_step()
{
$this->resetErrorBag();
$this->validateFields();
$this->form_step++;
}
public function prev_step()
{
$this->form_step--;
}
public function reset_recipe_image()
{
$this->recipe_image = null;
}
public function reset_step_image($index)
{
$this->guide_steps[$index]['step_image'] = null;
}
public function render()
{
return view('livewire.recipe-wizard');
}
public function add_ingredient()
{
$this->ingredients[] = $this->ingredient;
}
public function remove_ingredient($index)
{
unset($this->ingredients[$index]);
// reshuffle indexes after deleting
$this->ingredients = array_values($this->ingredients);
if (empty($this->ingredients)){
$this->ingredients[] = $this->ingredient;
}
}
public function add_step()
{
$this->guide_steps[] = $this->guide_step;
}
public function remove_step($index)
{
unset($this->guide_steps[$index]);
// reshuffle indexes after deleting
$this->guide_steps = array_values($this->guide_steps);
if (empty($this->guide_steps)){
$this->guide_steps[] = $this->guide_step;
}
}
public function store()
{
$this->resetErrorBag();
if ($this->form_step == 3){
$this->validate([
'guide_steps.*.step_image' => ['nullable', 'mimes:jpeg,png,webp'],
'guide_steps.*.step_text' => ['required','string', 'max:255'],
]);
}
$ingredientsGrouped = collect($this->ingredients)
->groupBy('ingredient_name') // Group by ingredient name
->map(function ($group) {
return [
'ingredient_name' => $group->first()['ingredient_name'], // Take the first name
'ingredient_quantity' => $group->sum('ingredient_quantity'), // Sum quantities
'ingredient_unit' => $group->first()['ingredient_unit'], // Take the first unit
];
});
$finalIngredients = []; // Data to attach to pivot table
foreach ($ingredientsGrouped as $ingredientData){
// Check if the ingredient exists (get the existed one or create and save to database)
$ingredient = Ingredient::firstOrCreate(
['name' => trim($ingredientData['ingredient_name'])], // Match by name
);
// Prepare data for pivot table
$finalIngredients[] = [
'id' => $ingredient->id,
'quantity' => $ingredientData['ingredient_quantity'],
'unit_id' => $ingredientData['ingredient_unit'],
];
}
// Check if temp image exists then store
if ($this->recipe_image){
$recipe_image_path = $this->recipe_image->store('recipes-images', 'public');
}else{
$recipe_image_path = null;
}
// Create the recipe
$recipe_data = [
'name' => $this->recipe_name,
'description' => $this->recipe_description,
'image' => $recipe_image_path,
'cook_time' => $this->recipe_time,
'servings' => $this->recipe_servings,
'dish_category_id' => $this->recipe_category,
'cuisine_id' => $this->recipe_cuisine,
'menu_id' => $this->recipe_menu,
];
if ($recipe_data['image'] === null){
unset($recipe_data['image']);
}
$recipe = Auth::user()->recipes()->create($recipe_data);
$recipe->ingredients()->attach(
collect($finalIngredients)->mapWithKeys(function ($ingredient){
return [$ingredient['id'] => [
'quantity' => $ingredient['quantity'],
'unit_id' => $ingredient['unit_id'],
'created_at' => now(),
'updated_at' => now(),
]];
})->toArray()
);
$groupedSteps = collect($this->guide_steps)->map(function ($step, $index) use ($recipe){
return [
'recipe_id' => $recipe->id,
'step_number' => $index + 1,
'step_text' => trim($step['step_text']),
'step_image' => $step['step_image']
? $step['step_image']->store('guides-images', 'public')
: 'recipes-images/default/default_photo.png',
'created_at' => now(),
'updated_at' => now(),
];
})->toArray();
GuideStep::insert($groupedSteps);
session()->flash('recipe_created', 'Recipe created successfully!');
$this->redirect('/recipes/create');
}
public function validateFields(){
if ($this->form_step == 1){
$this->validate([
// Step 1 rules
'recipe_name' => ['required', 'string', 'unique:recipes,name', 'max:255'],
'recipe_description' => ['nullable', 'string', 'max:255'],
'recipe_category' => ['required'],
'recipe_cuisine' => ['required'],
'recipe_menu' => ['nullable'],
'recipe_time' => ['required', 'date_format:H:i', 'not_in:00:00'],
'recipe_servings' => ['required', 'integer', 'min:1', 'max:99'],
]);
}else if ($this->form_step == 2){
$this->validate([
// Step 2 rules
'ingredients' => ['required', 'array'],
'ingredients.*.ingredient_name' => ['required', 'string', 'max:255'],
'ingredients.*.ingredient_quantity' => ['required', 'numeric', 'min:0.1', 'max:999.99', 'regex:/^\d{1,3}(\.\d{1,2})?$/'],
'ingredients.*.ingredient_unit' => ['required'],
]);
}
}
protected function messages()
{
return [
// Step 1 messages
'recipe_image.mimes' => 'The recipe image must be a file of type: jpeg, png, webp',
'recipe_name.required' => 'Recipe name is required',
'recipe_name.string' => 'Recipe name must be a string',
'recipe_name.unique:App\Models\Recipe, name' => 'This recipe name has already been taken',
'recipe_name.max' => 'This recipe name is too long',
'recipe_description.string' => 'Description must be a string',
'recipe_description.max' => 'Description is too long',
'recipe_category.required' => 'Required',
'recipe_cuisine.required' => 'Required',
'recipe_time.required' => 'Required',
'recipe_time.not_in' => 'Invalid format',
'recipe_servings.required' => 'Required',
// END Step 1 messages
// Step 2 messages
'ingredients.*.ingredient_name.required' => 'Required',
'ingredients.*.ingredient_name.string' => 'Invalid type',
'ingredients.*.ingredient_name.max' => 'Too long',
'ingredients.*.ingredient_quantity.required' => 'Required',
'ingredients.*.ingredient_quantity.decimal' => 'Invalid type',
'ingredients.*.ingredient_quantity.max' => 'Too big',
'ingredients.*.ingredient_quantity.min' => 'Too small',
'ingredients.*.ingredient_quantity.regex' => 'Invalid format',
'ingredients.*.ingredient_unit.required' => 'Required',
// END Step 2 messages
// Step 3 messages
'guide_steps.*.step_image.mimes' => 'The image must be a file of type: jpeg, png, webp',
'guide_steps.*.step_text.required' => 'Required',
'guide_steps.*.step_text.string' => 'Invalid type',
'guide_steps.*.step_text.max' => 'Text is too long',
// END Step 3 messages
];
}
}
I'm having difficulty reusing my wizard form so that it can both create new recipes and edit existing ones.
- I cannot use model binding in Livewire 3.
- I can use 'legacy_model_binding' => true, but this is a workaround and not a sustainable - option.
- Form objects are not very practical when I need to reuse the form for creating and updating a recipe in one component (because of
unique
validation rule). - For now, the most obvious and simple option is to create two forms, one for creating and one for editing a recipe. Each form will use form objects to reduce the amount of code.
I'd also like to point out that my form is composed of 3 parts, with dynamic form fields logic implemented in steps 2 and 3. The store method also includes logic for creating an ingredient if it doesn't already exist in the database.
Does anyone have any ideas about this? (sorry for long code)
Code
class RecipeWizard extends Component
{
use WithFileUploads;
public $form_step = 1;
public $dishCategories;
public $cuisines;
public $menus;
public $units;
// Fields Step 1
#[Validate]
#[Rule(['nullable','mimes:jpeg,png,webp'])]
public $recipe_image;
public $recipe_name;
public $recipe_description;
public $recipe_category;
public $recipe_cuisine;
public $recipe_menu;
public $recipe_time;
public $recipe_servings;
// Fields Step 2
public $ingredients = [
[]
];
public $ingredient = ['ingredient_name' => null, 'ingredient_quantity' => null, 'ingredient_unit' => null];
// Fields Step 3
public $guide_steps = [
['step_image' => null, 'step_text' => null],
];
public $guide_step = ['step_image' => null, 'step_text' => null];
public function mount()
{
$this->dishCategories = DishCategory::get();
$this->cuisines = Cuisine::get();
$this->menus = Menu::get();
$this->units = Unit::get();
}
public function next_step()
{
$this->resetErrorBag();
$this->validateFields();
$this->form_step++;
}
public function prev_step()
{
$this->form_step--;
}
public function reset_recipe_image()
{
$this->recipe_image = null;
}
public function reset_step_image($index)
{
$this->guide_steps[$index]['step_image'] = null;
}
public function render()
{
return view('livewire.recipe-wizard');
}
public function add_ingredient()
{
$this->ingredients[] = $this->ingredient;
}
public function remove_ingredient($index)
{
unset($this->ingredients[$index]);
// reshuffle indexes after deleting
$this->ingredients = array_values($this->ingredients);
if (empty($this->ingredients)){
$this->ingredients[] = $this->ingredient;
}
}
public function add_step()
{
$this->guide_steps[] = $this->guide_step;
}
public function remove_step($index)
{
unset($this->guide_steps[$index]);
// reshuffle indexes after deleting
$this->guide_steps = array_values($this->guide_steps);
if (empty($this->guide_steps)){
$this->guide_steps[] = $this->guide_step;
}
}
public function store()
{
$this->resetErrorBag();
if ($this->form_step == 3){
$this->validate([
'guide_steps.*.step_image' => ['nullable', 'mimes:jpeg,png,webp'],
'guide_steps.*.step_text' => ['required','string', 'max:255'],
]);
}
$ingredientsGrouped = collect($this->ingredients)
->groupBy('ingredient_name') // Group by ingredient name
->map(function ($group) {
return [
'ingredient_name' => $group->first()['ingredient_name'], // Take the first name
'ingredient_quantity' => $group->sum('ingredient_quantity'), // Sum quantities
'ingredient_unit' => $group->first()['ingredient_unit'], // Take the first unit
];
});
$finalIngredients = []; // Data to attach to pivot table
foreach ($ingredientsGrouped as $ingredientData){
// Check if the ingredient exists (get the existed one or create and save to database)
$ingredient = Ingredient::firstOrCreate(
['name' => trim($ingredientData['ingredient_name'])], // Match by name
);
// Prepare data for pivot table
$finalIngredients[] = [
'id' => $ingredient->id,
'quantity' => $ingredientData['ingredient_quantity'],
'unit_id' => $ingredientData['ingredient_unit'],
];
}
// Check if temp image exists then store
if ($this->recipe_image){
$recipe_image_path = $this->recipe_image->store('recipes-images', 'public');
}else{
$recipe_image_path = null;
}
// Create the recipe
$recipe_data = [
'name' => $this->recipe_name,
'description' => $this->recipe_description,
'image' => $recipe_image_path,
'cook_time' => $this->recipe_time,
'servings' => $this->recipe_servings,
'dish_category_id' => $this->recipe_category,
'cuisine_id' => $this->recipe_cuisine,
'menu_id' => $this->recipe_menu,
];
if ($recipe_data['image'] === null){
unset($recipe_data['image']);
}
$recipe = Auth::user()->recipes()->create($recipe_data);
$recipe->ingredients()->attach(
collect($finalIngredients)->mapWithKeys(function ($ingredient){
return [$ingredient['id'] => [
'quantity' => $ingredient['quantity'],
'unit_id' => $ingredient['unit_id'],
'created_at' => now(),
'updated_at' => now(),
]];
})->toArray()
);
$groupedSteps = collect($this->guide_steps)->map(function ($step, $index) use ($recipe){
return [
'recipe_id' => $recipe->id,
'step_number' => $index + 1,
'step_text' => trim($step['step_text']),
'step_image' => $step['step_image']
? $step['step_image']->store('guides-images', 'public')
: 'recipes-images/default/default_photo.png',
'created_at' => now(),
'updated_at' => now(),
];
})->toArray();
GuideStep::insert($groupedSteps);
session()->flash('recipe_created', 'Recipe created successfully!');
$this->redirect('/recipes/create');
}
public function validateFields(){
if ($this->form_step == 1){
$this->validate([
// Step 1 rules
'recipe_name' => ['required', 'string', 'unique:recipes,name', 'max:255'],
'recipe_description' => ['nullable', 'string', 'max:255'],
'recipe_category' => ['required'],
'recipe_cuisine' => ['required'],
'recipe_menu' => ['nullable'],
'recipe_time' => ['required', 'date_format:H:i', 'not_in:00:00'],
'recipe_servings' => ['required', 'integer', 'min:1', 'max:99'],
]);
}else if ($this->form_step == 2){
$this->validate([
// Step 2 rules
'ingredients' => ['required', 'array'],
'ingredients.*.ingredient_name' => ['required', 'string', 'max:255'],
'ingredients.*.ingredient_quantity' => ['required', 'numeric', 'min:0.1', 'max:999.99', 'regex:/^\d{1,3}(\.\d{1,2})?$/'],
'ingredients.*.ingredient_unit' => ['required'],
]);
}
}
protected function messages()
{
return [
// Step 1 messages
'recipe_image.mimes' => 'The recipe image must be a file of type: jpeg, png, webp',
'recipe_name.required' => 'Recipe name is required',
'recipe_name.string' => 'Recipe name must be a string',
'recipe_name.unique:App\Models\Recipe, name' => 'This recipe name has already been taken',
'recipe_name.max' => 'This recipe name is too long',
'recipe_description.string' => 'Description must be a string',
'recipe_description.max' => 'Description is too long',
'recipe_category.required' => 'Required',
'recipe_cuisine.required' => 'Required',
'recipe_time.required' => 'Required',
'recipe_time.not_in' => 'Invalid format',
'recipe_servings.required' => 'Required',
// END Step 1 messages
// Step 2 messages
'ingredients.*.ingredient_name.required' => 'Required',
'ingredients.*.ingredient_name.string' => 'Invalid type',
'ingredients.*.ingredient_name.max' => 'Too long',
'ingredients.*.ingredient_quantity.required' => 'Required',
'ingredients.*.ingredient_quantity.decimal' => 'Invalid type',
'ingredients.*.ingredient_quantity.max' => 'Too big',
'ingredients.*.ingredient_quantity.min' => 'Too small',
'ingredients.*.ingredient_quantity.regex' => 'Invalid format',
'ingredients.*.ingredient_unit.required' => 'Required',
// END Step 2 messages
// Step 3 messages
'guide_steps.*.step_image.mimes' => 'The image must be a file of type: jpeg, png, webp',
'guide_steps.*.step_text.required' => 'Required',
'guide_steps.*.step_text.string' => 'Invalid type',
'guide_steps.*.step_text.max' => 'Text is too long',
// END Step 3 messages
];
}
}
Share
Improve this question
asked Mar 18 at 6:56
d.shvedd.shved
231 silver badge7 bronze badges
1 Answer
Reset to default 2I would go with form objects. The unique rule allows to ignore a defined ID like so
Rule::unique('recipes,name')->ignore($existingId),