Add ingredient separators (#10)

* Add weight handling to ingredient sortable

* Add frontend logic for recipe ingredients "separator"

* Add "recipe separator" model

* Update ingredient handlers on recipe save

* Combine ingredients and separators handler in recipe edit

* Handle recipe ingredient separators in recipe show

* Fix handling of old recipe form data
This commit is contained in:
Christopher Charbonneau Wells 2021-03-26 08:58:37 -07:00 committed by GitHub
parent 15e016692c
commit 0407899496
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1467 additions and 539 deletions

View File

@ -2,9 +2,9 @@
namespace App\Http\Controllers;
use App\Models\Food;
use App\Models\IngredientAmount;
use App\Models\Recipe;
use App\Models\RecipeSeparator;
use App\Models\RecipeStep;
use App\Rules\ArrayNotEmpty;
use App\Rules\StringIsDecimalOrFraction;
@ -84,11 +84,10 @@ class RecipeController extends Controller
$ingredients = [];
if ($old = old('ingredients')) {
foreach ($old['id'] as $key => $ingredient_id) {
if (empty($ingredient_id)) {
continue;
}
$ingredients[] = [
'original_key' => $old['original_key'][$key],
'type' => 'ingredient',
'key' => $old['key'][$key],
'weight' => $old['weight'][$key],
'amount' => $old['amount'][$key],
'unit' => $old['unit'][$key],
'ingredient_id' => $ingredient_id,
@ -101,7 +100,9 @@ class RecipeController extends Controller
else {
foreach ($recipe->ingredientAmounts as $key => $ingredientAmount) {
$ingredients[] = [
'original_key' => $key,
'type' => 'ingredient',
'key' => $key,
'weight' => $ingredientAmount->weight,
'amount' => $ingredientAmount->amount_formatted,
'unit' => $ingredientAmount->unit,
'ingredient_id' => $ingredientAmount->ingredient_id,
@ -112,6 +113,28 @@ class RecipeController extends Controller
}
}
$separators = [];
if ($old = old('separators')) {
foreach ($old['key'] as $index => $key) {
$separators[] = [
'type' => 'separator',
'key' => $old['key'][$index],
'weight' => $old['weight'][$index],
'text' => $old['text'][$index],
];
}
}
else {
foreach ($recipe->ingredientSeparators as $key => $ingredientSeparator) {
$separators[] = [
'type' => 'separator',
'key' => $key,
'weight' => $ingredientSeparator->weight,
'text' => $ingredientSeparator->text,
];
}
}
$steps = [];
if ($old = old('steps')) {
foreach ($old['step'] as $key => $step) {
@ -119,7 +142,7 @@ class RecipeController extends Controller
continue;
}
$steps[] = [
'original_key' => $old['original_key'][$key],
'key' => $old['key'][$key],
'step_default' => $step,
];
}
@ -127,7 +150,7 @@ class RecipeController extends Controller
else {
foreach ($recipe->steps as $key => $step) {
$steps[] = [
'original_key' => $key,
'key' => $key,
'step_default' => $step->step,
];
}
@ -142,7 +165,7 @@ class RecipeController extends Controller
return view('recipes.edit')
->with('recipe', $recipe)
->with('recipe_tags', $recipe_tags)
->with('ingredients', $ingredients)
->with('ingredients_list', new Collection([...$ingredients, ...$separators]))
->with('steps', $steps)
->with('ingredients_units', Nutrients::$units);
}
@ -175,15 +198,24 @@ class RecipeController extends Controller
'ingredients.unit' => ['required', 'array'],
'ingredients.unit.*' => ['required_with:ingredients.id.*'],
'ingredients.detail' => ['required', 'array'],
'ingredients.detail.*' => 'nullable|string',
'ingredients.detail.*' => ['nullable', 'string'],
'ingredients.id' => ['required', 'array', new ArrayNotEmpty],
'ingredients.id.*' => 'required_with:ingredients.amount.*|nullable',
'ingredients.type' => ['required', 'array', new ArrayNotEmpty],
'ingredients.type.*' => ['required_with:ingredients.id.*', 'nullable', new UsesIngredientTrait()],
'ingredients.original_key' => 'nullable|array',
'ingredients.key' => ['required', 'array', new ArrayNotEmpty],
'ingredients.key.*' => ['nullable', 'int'],
'ingredients.weight' => ['required', 'array', new ArrayNotEmpty],
'ingredients.weight.*' => ['required', 'int'],
'separators.key' => ['nullable', 'array'],
'separators.key.*' => ['nullable', 'int'],
'separators.weight' => ['nullable', 'array'],
'separators.weight.*' => ['required', 'int'],
'separators.text' => ['nullable', 'array'],
'separators.text.*' => ['nullable', 'string'],
'steps.step' => ['required', 'array', new ArrayNotEmpty],
'steps.step.*' => 'nullable|string',
'steps.original_key' => 'nullable|array',
'steps.step.*' => ['nullable', 'string'],
'steps.key' => ['nullable', 'array'],
]);
// Validate that no ingredients are recursive.
@ -208,53 +240,9 @@ class RecipeController extends Controller
try {
DB::transaction(function () use ($recipe, $input) {
$recipe->saveOrFail();
// Delete any removed ingredients.
$removed = array_diff($recipe->ingredientAmounts->keys()->all(), $input['ingredients']['original_key']);
foreach ($removed as $removed_key) {
$recipe->ingredientAmounts[$removed_key]->delete();
}
// Add/update current ingredients.
$ingredient_amounts = [];
$weight = 0;
foreach (array_filter($input['ingredients']['id']) as $key => $ingredient_id) {
if (!is_null($input['ingredients']['original_key'][$key])) {
$ingredient_amounts[$key] = $recipe->ingredientAmounts[$input['ingredients']['original_key'][$key]];
}
else {
$ingredient_amounts[$key] = new IngredientAmount();
}
$ingredient_amounts[$key]->fill([
'amount' => Number::floatFromString($input['ingredients']['amount'][$key]),
'unit' => $input['ingredients']['unit'][$key],
'detail' => $input['ingredients']['detail'][$key],
'weight' => $weight++,
]);
$ingredient_amounts[$key]->ingredient()
->associate($input['ingredients']['type'][$key]::where('id', $ingredient_id)->first());
}
$recipe->ingredientAmounts()->saveMany($ingredient_amounts);
$steps = [];
$number = 1;
// Delete any removed steps.
$removed = array_diff($recipe->steps->keys()->all(), $input['steps']['original_key']);
foreach ($removed as $removed_key) {
$recipe->steps[$removed_key]->delete();
}
foreach (array_filter($input['steps']['step']) as $key => $step) {
if (!is_null($input['steps']['original_key'][$key])) {
$steps[$key] = $recipe->steps[$input['steps']['original_key'][$key]];
}
else {
$steps[$key] = new RecipeStep();
}
$steps[$key]->fill(['number' => $number++, 'step' => $step]);
}
$recipe->steps()->saveMany($steps);
$this->updateIngredients($recipe, $input);
$this->updateIngredientSeparators($recipe, $input);
$this->updateSteps($recipe, $input);
});
} catch (\Exception $e) {
DB::rollBack();
@ -289,6 +277,114 @@ class RecipeController extends Controller
return redirect()->route('recipes.show', $recipe);
}
/**
* Updates recipe ingredients data based on input.
*
* @param \App\Models\Recipe $recipe
* @param array $input
*
* @throws \Exception
*/
private function updateIngredients(Recipe $recipe, array $input): void {
// Delete any removed ingredients.
$removed = array_diff($recipe->ingredientAmounts->keys()->all(), $input['ingredients']['key']);
foreach ($removed as $removed_key) {
$recipe->ingredientAmounts[$removed_key]->delete();
}
// Add/update current ingredients.
$ingredient_amounts = [];
foreach (array_filter($input['ingredients']['id']) as $key => $ingredient_id) {
if (!is_null($input['ingredients']['key'][$key])) {
$ingredient_amounts[$key] = $recipe->ingredientAmounts[$input['ingredients']['key'][$key]];
}
else {
$ingredient_amounts[$key] = new IngredientAmount();
}
$ingredient_amounts[$key]->fill([
'amount' => Number::floatFromString($input['ingredients']['amount'][$key]),
'unit' => $input['ingredients']['unit'][$key],
'detail' => $input['ingredients']['detail'][$key],
'weight' => (int) $input['ingredients']['weight'][$key],
]);
$ingredient_amounts[$key]->ingredient()
->associate($input['ingredients']['type'][$key]::where('id', $ingredient_id)->first());
}
$recipe->ingredientAmounts()->saveMany($ingredient_amounts);
}
/**
* Updates recipe steps data based on input.
*
* @param \App\Models\Recipe $recipe
* @param array $input
*
* @throws \Exception
*/
private function updateSteps(Recipe $recipe, array $input): void {
$steps = [];
$number = 1;
// Delete any removed steps.
$removed = array_diff($recipe->steps->keys()->all(), $input['steps']['key']);
foreach ($removed as $removed_key) {
$recipe->steps[$removed_key]->delete();
}
foreach (array_filter($input['steps']['step']) as $key => $step) {
if (!is_null($input['steps']['key'][$key])) {
$steps[$key] = $recipe->steps[$input['steps']['key'][$key]];
}
else {
$steps[$key] = new RecipeStep();
}
$steps[$key]->fill(['number' => $number++, 'step' => $step]);
}
$recipe->steps()->saveMany($steps);
}
/**
* Updates recipe ingredient separators data based on input.
*
* @param \App\Models\Recipe $recipe
* @param array $input
*
* @throws \Exception
*/
private function updateIngredientSeparators(Recipe $recipe, array $input): void {
// Take no action of remove all separators
if (!isset($input['separators']) || empty($input['separators'])) {
if ($recipe->ingredientSeparators->isNotEmpty()) {
$recipe->ingredientSeparators()->delete();
}
return;
}
// Delete any removed separators.
$removed = array_diff($recipe->ingredientSeparators->keys()->all(), $input['separators']['key']);
foreach ($removed as $removed_key) {
$recipe->ingredientSeparators[$removed_key]->delete();
}
// Add/update current separators.
$ingredient_separators = [];
foreach ($input['separators']['key'] as $index => $key) {
if (!is_null($key)) {
$ingredient_separators[$index] = $recipe->ingredientSeparators[$key];
}
else {
$ingredient_separators[$index] = new RecipeSeparator();
}
$ingredient_separators[$index]->fill([
'container' => 'ingredients',
'text' => $input['separators']['text'][$index],
'weight' => (int) $input['separators']['weight'][$index],
]);
}
$recipe->ingredientSeparators()->saveMany($ingredient_separators);
}
/**
* Confirm removal of specified resource.
*/

View File

@ -72,6 +72,7 @@ use Spatie\Tags\HasTags;
* @method static \Illuminate\Database\Eloquent\Builder|Food withAnyTagsOfAnyType($tags)
* @mixin \Eloquent
* @method static \Illuminate\Database\Eloquent\Builder|Food withUniqueSlugConstraints(\Illuminate\Database\Eloquent\Model $model, string $attribute, array $config, string $slug)
* @method static \Database\Factories\FoodFactory factory(...$parameters)
*/
final class Food extends Model
{

View File

@ -3,7 +3,6 @@
namespace App\Models;
use App\Support\Nutrients;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -37,8 +36,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
*/
final class Goal extends Model
{
use HasFactory;
/**
* Supported options for thr frequency attribute.
*/

View File

@ -43,6 +43,7 @@ use Illuminate\Support\Pluralizer;
* @mixin \Eloquent
* @property-read string|null $unit_formatted
* @property-read string $nutrients_summary
* @method static \Database\Factories\IngredientAmountFactory factory(...$parameters)
*/
final class IngredientAmount extends Model
{

View File

@ -48,6 +48,7 @@ use Illuminate\Database\Eloquent\Relations\MorphToMany;
* @mixin \Eloquent
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\IngredientAmount[] $ingredients
* @property-read int|null $ingredients_count
* @method static \Database\Factories\JournalEntryFactory factory(...$parameters)
*/
final class JournalEntry extends Model
{

View File

@ -10,6 +10,7 @@ use ElasticScoutDriverPlus\QueryDsl;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
use Laravel\Scout\Searchable;
use Spatie\Image\Manipulations;
use Spatie\MediaLibrary\HasMedia;
@ -69,6 +70,11 @@ use Spatie\Tags\HasTags;
* @method static \Illuminate\Database\Eloquent\Builder|Recipe whereDescriptionDelta($value)
* @property-read \Spatie\MediaLibrary\MediaCollections\Models\Collections\MediaCollection|Media[] $media
* @property-read int|null $media_count
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\RecipeSeparator[] $ingredientSeparators
* @property-read int|null $ingredient_separators_count
* @method static \Database\Factories\RecipeFactory factory(...$parameters)
* @method static \Illuminate\Database\Eloquent\Builder|Recipe whereTimeCook($value)
* @property-read Collection $ingredients_list
*/
final class Recipe extends Model implements HasMedia
{
@ -158,6 +164,16 @@ final class Recipe extends Model implements HasMedia
return round($this->weight / $this->servings, 2);
}
/**
* Get the ingredients list (ingredient amounts and separators).
*/
public function getIngredientsListAttribute(): Collection {
return new Collection([
...$this->ingredientAmounts,
...$this->ingredientSeparators,
]);
}
/**
* Get the steps for this Recipe.
*/
@ -165,6 +181,19 @@ final class Recipe extends Model implements HasMedia
return $this->hasMany(RecipeStep::class)->orderBy('number');
}
/**
* Get "separators" for the ingredients.
*
* Separators are used to adding headings or simple separations to the
* ingredients _list_ for a recipe. Their position is defined by weights
* compatible with ingredient weights.
*/
public function ingredientSeparators(): HasMany {
return $this->hasMany(RecipeSeparator::class)
->where('container', 'ingredients')
->orderBy('weight');
}
/**
* Add nutrient calculations handling to overloading.
*

View File

@ -0,0 +1,55 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\RecipeSeparator
*
* @property int $id
* @property int $recipe_id
* @property string $container
* @property int $weight
* @property string $text
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Recipe $recipe
* @method static \Illuminate\Database\Eloquent\Builder|RecipeSeparator newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|RecipeSeparator newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|RecipeSeparator query()
* @method static \Illuminate\Database\Eloquent\Builder|RecipeSeparator whereContainer($value)
* @method static \Illuminate\Database\Eloquent\Builder|RecipeSeparator whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|RecipeSeparator whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|RecipeSeparator whereRecipeId($value)
* @method static \Illuminate\Database\Eloquent\Builder|RecipeSeparator whereText($value)
* @method static \Illuminate\Database\Eloquent\Builder|RecipeSeparator whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|RecipeSeparator whereWeight($value)
* @mixin \Eloquent
*/
final class RecipeSeparator extends Model
{
/**
* @inheritdoc
*/
protected $fillable = [
'container',
'weight',
'text',
];
/**
* @inheritdoc
*/
protected $casts = [
'weight' => 'int',
];
/**
* Get the Recipe this step belongs to.
*/
public function recipe(): BelongsTo {
return $this->belongsTo(Recipe::class);
}
}

View File

@ -26,6 +26,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @method static \Illuminate\Database\Eloquent\Builder|RecipeStep whereStep($value)
* @method static \Illuminate\Database\Eloquent\Builder|RecipeStep whereUpdatedAt($value)
* @mixin \Eloquent
* @method static \Database\Factories\RecipeStepFactory factory(...$parameters)
*/
final class RecipeStep extends Model
{
@ -40,7 +41,7 @@ final class RecipeStep extends Model
];
/**
* The attributes that should be cast.
* @inheritdoc
*/
protected $casts = [
'number' => 'int',

View File

@ -37,6 +37,7 @@ use Illuminate\Support\Facades\Auth;
* @mixin \Eloquent
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\Goal[] $goals
* @property-read int|null $goals_count
* @method static \Database\Factories\UserFactory factory(...$parameters)
*/
final class User extends Authenticatable
{

View File

@ -42,6 +42,8 @@ class ArrayFormat
*
* @return array
* The flipped array.
*
* @todo Return Collection instead of array.
*/
public static function flipTwoDimensionalKeys(array $array): array {
$flipped = [];

View File

@ -0,0 +1,36 @@
<?php
use App\Models\Recipe;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateRecipeSeparatorsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('recipe_separators', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Recipe::class)->index()->constrained()->cascadeOnUpdate()->cascadeOnDelete();
$table->string('container')->index();
$table->unsignedInteger('weight')->index();
$table->longText('text')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('recipe_separators');
}
}

28
package-lock.json generated
View File

@ -5,13 +5,13 @@
"packages": {
"": {
"dependencies": {
"@shopify/draggable": "^1.0.0-beta.8",
"@shopify/draggable": "^1.0.0-beta.12",
"alpine-magic-helpers": "^1.1.0"
},
"devDependencies": {
"@tailwindcss/forms": "^0.2.1",
"@tailwindcss/typography": "^0.4.0",
"alpinejs": "^2.8.1",
"alpinejs": "^2.8.2",
"autoprefixer": "^10.2.5",
"axios": "^0.21.1",
"cross-env": "^7.0",
@ -1138,9 +1138,9 @@
}
},
"node_modules/@shopify/draggable": {
"version": "1.0.0-beta.8",
"resolved": "https://registry.npmjs.org/@shopify/draggable/-/draggable-1.0.0-beta.8.tgz",
"integrity": "sha512-9IeBPQM93Ad4qFKUopwuTClzoST/1OId4MaSd/8FB5ScCL2tl25UaOGNR8E2hjiL7xK4LN5+I1Ews6amS7YAiA=="
"version": "1.0.0-beta.12",
"resolved": "https://registry.npmjs.org/@shopify/draggable/-/draggable-1.0.0-beta.12.tgz",
"integrity": "sha512-Un/Dn61sv2er9yjDXLGWMauCOWBb0BMbm0yzmmrD+oUX2/x50yhNJASTsCRdndUCpWlqYfZH8jEfaOgTPsKc/g=="
},
"node_modules/@tailwindcss/forms": {
"version": "0.2.1",
@ -2044,9 +2044,9 @@
}
},
"node_modules/alpinejs": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-2.8.1.tgz",
"integrity": "sha512-ETJ/k0fbiBeP+OSd5Fhj70c+zb+YRzcVbyh5DVeLT3FBWMUeUvjOSWLi53IVLPSehaT2SKmB7w08WGF2jYTqNA=="
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-2.8.2.tgz",
"integrity": "sha512-5yOUtckn4CBp0qsHpo2qgjZyZit84uXvHbB7NJ27sn4FA6UlFl2i9PGUAdTXkcbFvvxDJBM+zpOD8RuNYFvQAw=="
},
"node_modules/ansi-colors": {
"version": "4.1.1",
@ -13684,9 +13684,9 @@
}
},
"@shopify/draggable": {
"version": "1.0.0-beta.8",
"resolved": "https://registry.npmjs.org/@shopify/draggable/-/draggable-1.0.0-beta.8.tgz",
"integrity": "sha512-9IeBPQM93Ad4qFKUopwuTClzoST/1OId4MaSd/8FB5ScCL2tl25UaOGNR8E2hjiL7xK4LN5+I1Ews6amS7YAiA=="
"version": "1.0.0-beta.12",
"resolved": "https://registry.npmjs.org/@shopify/draggable/-/draggable-1.0.0-beta.12.tgz",
"integrity": "sha512-Un/Dn61sv2er9yjDXLGWMauCOWBb0BMbm0yzmmrD+oUX2/x50yhNJASTsCRdndUCpWlqYfZH8jEfaOgTPsKc/g=="
},
"@tailwindcss/forms": {
"version": "0.2.1",
@ -14494,9 +14494,9 @@
}
},
"alpinejs": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-2.8.1.tgz",
"integrity": "sha512-ETJ/k0fbiBeP+OSd5Fhj70c+zb+YRzcVbyh5DVeLT3FBWMUeUvjOSWLi53IVLPSehaT2SKmB7w08WGF2jYTqNA=="
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-2.8.2.tgz",
"integrity": "sha512-5yOUtckn4CBp0qsHpo2qgjZyZit84uXvHbB7NJ27sn4FA6UlFl2i9PGUAdTXkcbFvvxDJBM+zpOD8RuNYFvQAw=="
},
"ansi-colors": {
"version": "4.1.1",

View File

@ -12,7 +12,7 @@
"devDependencies": {
"@tailwindcss/forms": "^0.2.1",
"@tailwindcss/typography": "^0.4.0",
"alpinejs": "^2.8.1",
"alpinejs": "^2.8.2",
"autoprefixer": "^10.2.5",
"axios": "^0.21.1",
"cross-env": "^7.0",
@ -25,7 +25,7 @@
"vue-template-compiler": "^2.6.12"
},
"dependencies": {
"@shopify/draggable": "^1.0.0-beta.8",
"@shopify/draggable": "^1.0.0-beta.12",
"alpine-magic-helpers": "^1.1.0"
}
}

View File

@ -4743,7 +4743,7 @@
// event listeners are registered (the mutation observer will take care of them)
this.initializeElements(this.$el, () => {}, componentForClone ? false : true); // Use mutation observer to detect new elements being added within this component at run-time.
this.initializeElements(this.$el, () => {}, componentForClone); // Use mutation observer to detect new elements being added within this component at run-time.
// Alpine's just so darn flexible amirite?
this.listenForNewElementsToInitialize();
@ -4832,15 +4832,15 @@
});
}
initializeElements(rootEl, extraVars = () => {}, shouldRegisterListeners = true) {
initializeElements(rootEl, extraVars = () => {}, componentForClone = false) {
this.walkAndSkipNestedComponents(rootEl, el => {
// Don't touch spawns from for loop
if (el.__x_for_key !== undefined) return false; // Don't touch spawns from if directives
if (el.__x_inserted_me !== undefined) return false;
this.initializeElement(el, extraVars, shouldRegisterListeners);
this.initializeElement(el, extraVars, componentForClone ? false : true);
}, el => {
el.__x = new Component(el);
if (!componentForClone) el.__x = new Component(el);
});
this.executeAndClearRemainingShowDirectiveStack();
this.executeAndClearNextTickStack(rootEl);
@ -5070,7 +5070,7 @@
}
const Alpine = {
version: "2.8.1",
version: "2.8.2",
pauseMutationObserver: false,
magicProperties: {},
onComponentInitializeds: [],

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@
<x-slot name="header">
<h1 class="font-semibold text-xl text-gray-800 leading-tight">{{ $title }}</h1>
</x-slot>
<form method="POST" enctype="multipart/form-data" action="{{ ($recipe->exists ? route('recipes.update', $recipe) : route('recipes.store')) }}">
<form x-data method="POST" enctype="multipart/form-data" action="{{ ($recipe->exists ? route('recipes.update', $recipe) : route('recipes.store')) }}">
@if ($recipe->exists)@method('put')@endif
@csrf
<div class="flex flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0">
@ -126,19 +126,29 @@
<!-- Ingredients -->
<h3 class="mt-6 mb-2 font-extrabold text-lg">Ingredients</h3>
<div x-data class="ingredients space-y-4">
@forelse($ingredients as $ingredient)
@include('recipes.partials.ingredient-input', $ingredient)
@forelse($ingredients_list->sortBy('weight') as $item)
@if($item['type'] === 'ingredient')
@include('recipes.partials.ingredient-input', $item)
@elseif($item['type'] === 'separator')
@include('recipes.partials.separator-input', $item)
@endif
@empty
@include('recipes.partials.ingredient-input')
@endforelse
<div class="entry-template hidden">
@include('recipes.partials.ingredient-input')
<div class="templates hidden">
<div class="ingredient-template">
@include('recipes.partials.ingredient-input')
</div>
<div class="separator-template">
@include('recipes.partials.separator-input')
</div>
</div>
<x-inputs.icon-button type="button" color="green" x-on:click="addEntryNode($el);">
<svg class="h-10 w-10 pointer-events-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z" clip-rule="evenodd" />
</svg>
</x-inputs.icon-button>
<x-inputs.button type="button" color="green" x-on:click="addNodeFromTemplate($el, 'ingredient');">
Add Ingredient
</x-inputs.button>
<x-inputs.button type="button" color="blue" x-on:click="addNodeFromTemplate($el, 'separator');">
Add Separator
</x-inputs.button>
</div>
<!-- Steps -->
@ -149,18 +159,18 @@
@empty
@include('recipes.partials.step-input')
@endforelse
<div class="entry-template hidden">
@include('recipes.partials.step-input')
<div class="templates hidden">
<div class="step-template">
@include('recipes.partials.step-input')
</div>
</div>
<x-inputs.icon-button type="button" color="green" x-on:click="addEntryNode($el);">
<svg class="h-10 w-10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z" clip-rule="evenodd" />
</svg>
</x-inputs.icon-button>
<x-inputs.button type="button" color="green" x-on:click="addNodeFromTemplate($el, 'step');">
Add Step
</x-inputs.button>
</div>
<div x-data class="flex items-center justify-end mt-4">
<x-inputs.button x-on:click="prepareForm();" class="ml-3">
<div class="flex items-center justify-end mt-4">
<x-inputs.button x-on:click="prepareForm($el);" class="ml-3">
{{ ($recipe->exists ? 'Save' : 'Add') }}
</x-inputs.button>
</div>
@ -202,9 +212,9 @@
description.setContents(JSON.parse(document.querySelector('input[name="description_delta"]').value));
} catch (e) {}
// Activate ingredient draggables.
new Draggable.Sortable(document.querySelector('.ingredients'), {
draggable: '.ingredient',
// Activate ingredient sortable.
const ingredientsSortable = new Draggable.Sortable(document.querySelector('.ingredients'), {
draggable: '.draggable',
handle: '.draggable-handle',
mirror: {
appendTo: '.ingredients',
@ -212,9 +222,18 @@
},
})
// Recalculate weight (order) of all ingredients.
ingredientsSortable.on('drag:stopped', (e) => {
Array.from(e.sourceContainer.children)
.filter(el => el.classList.contains('draggable'))
.forEach((el, index) => {
el.querySelector('input[name$="[weight][]"]').value = index;
});
})
// Activate step draggables.
new Draggable.Sortable(document.querySelector('.steps'), {
draggable: '.step',
draggable: '.draggable',
handle: '.draggable-handle',
mirror: {
appendTo: '.steps',
@ -224,29 +243,39 @@
</script>
<script type="text/javascript">
/**
* Adds a set of entry form fields from the template.
* Adds a node to ingredients or steps based on a template.
*
* @param {object} $el Entry lines parent element.
* @param {object} $el Parent element.
* @param {string} type Template type -- "ingredient", "separator", or "step".
*/
let addEntryNode = ($el) => {
// Create clone of template entry.
let template = $el.querySelector(':scope .entry-template');
let newEntry = template.cloneNode(true).firstElementChild;
let addNodeFromTemplate = ($el, type) => {
// Create clone of relevant template.
const templates = $el.querySelector(`:scope .templates`);
const template = templates.querySelector(`:scope .${type}-template`);
const newNode = template.cloneNode(true).firstElementChild;
// Insert new entry before add button.
$el.insertBefore(newEntry, template);
// Set weight based on previous sibling.
const lastWeight = templates.previousElementSibling.querySelector('input[name$="[weight][]"]');
if (lastWeight && lastWeight.value) {
newNode.querySelector('input[name$="[weight][]"]').value = Number.parseInt(lastWeight.value) + 1;
}
// Insert new node before templates.
$el.insertBefore(newNode, templates);
}
/**
* Prepare form values for submit.
*
* @param {object} $el Form element.
*/
let prepareForm = () => {
let prepareForm = ($el) => {
// Remove any hidden templates before form submit.
document.querySelectorAll(':scope .entry-template').forEach(e => e.remove());
$el.querySelectorAll(':scope .templates').forEach(e => e.remove());
// Add description values to hidden fields.
document.querySelector('input[name="description_delta"]').value = JSON.stringify(description.getContents());
document.querySelector('input[name="description"]').value = description.root.innerHTML
$el.querySelector('input[name="description_delta"]').value = JSON.stringify(description.getContents());
$el.querySelector('input[name="description"]').value = description.root.innerHTML
// Remove extraneous spaces from rendered result.
.replaceAll('<p><br></p>', '');
}

View File

@ -1,5 +1,6 @@
<div class="ingredient">
<x-inputs.input type="hidden" name="ingredients[original_key][]" :value="$original_key ?? null" />
<div class="ingredient draggable">
<x-inputs.input type="hidden" name="ingredients[key][]" :value="$key ?? null" />
<x-inputs.input type="hidden" name="ingredients[weight][]" :value="$weight ?? null" />
<div class="flex items-center space-x-2">
<div class="flex flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0 w-full">
<div class="draggable-handle self-center text-gray-500 bg-gray-100 w-full md:w-auto p-2 cursor-move">

View File

@ -0,0 +1,29 @@
<div class="separator draggable">
<x-inputs.input type="hidden" name="separators[key][]" :value="$key ?? null" />
<x-inputs.input type="hidden" name="separators[weight][]" :value="$weight ?? null" />
<div class="flex items-center space-x-2">
<div class="flex flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0 w-full">
<div class="draggable-handle self-center text-gray-500 bg-gray-100 w-full md:w-auto p-2 cursor-move">
<svg class="h-6 w-6 mx-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd" />
</svg>
</div>
<x-inputs.label for="source" value="Separator text" class="hidden" />
<x-inputs.input name="separators[text][]"
type="text"
placeholder="Separator text (optional)"
class="block w-full"
:value="$text ?? null" />
</div>
<div class="flex-none">
<x-inputs.icon-button type="button"
color="red"
x-on:click="$event.target.parentNode.parentNode.parentNode.remove();">
<svg class="h-8 w-8 pointer-events-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</x-inputs.icon-button>
</div>
</div>
</div>

View File

@ -1,5 +1,5 @@
<div class="step">
<x-inputs.input type="hidden" name="steps[original_key][]" :value="$original_key ?? null" />
<div class="step draggable">
<x-inputs.input type="hidden" name="steps[key][]" :value="$key ?? null" />
<div class="flex flex-row mb-4 space-x-4">
<div class="draggable-handle self-center text-gray-500 bg-gray-100 p-2 cursor-move">
<svg class="h-10 w-10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">

View File

@ -52,23 +52,34 @@
</h1>
<div class="prose prose-lg">
<ul class="space-y-2">
@foreach($recipe->ingredientAmounts as $ia)
<li>
<span>
{{ \App\Support\Number::fractionStringFromFloat($ia->amount) }}
@if($ia->unitFormatted){{ $ia->unitFormatted }}@endif
@if($ia->ingredient->type === \App\Models\Recipe::class)
<a class="text-gray-500 hover:text-gray-700 hover:border-gray-300"
href="{{ route('recipes.show', $ia->ingredient) }}">
{{ $ia->ingredient->name }}
</a>
@else
{{ $ia->ingredient->name }}@if($ia->ingredient->detail), {{ $ia->ingredient->detail }}@endif
@endif
@if($ia->detail)<span class="text-gray-500">{{ $ia->detail }}</span>@endif
<div x-show="showNutrientsSummary" class="text-sm text-gray-500">{{ $ia->nutrients_summary }}</div>
</span>
</li>
@foreach($recipe->ingredientsList->sortBy('weight') as $item)
@if($item::class === \App\Models\IngredientAmount::class)
<li>
<span>
{{ \App\Support\Number::fractionStringFromFloat($item->amount) }}
@if($item->unitFormatted){{ $item->unitFormatted }}@endif
@if($item->ingredient->type === \App\Models\Recipe::class)
<a class="text-gray-500 hover:text-gray-700 hover:border-gray-300"
href="{{ route('recipes.show', $item->ingredient) }}">
{{ $item->ingredient->name }}
</a>
@else
{{ $item->ingredient->name }}@if($item->ingredient->detail), {{ $item->ingredient->detail }}@endif
@endif
@if($item->detail)<span class="text-gray-500">{{ $item->detail }}</span>@endif
<div x-show="showNutrientsSummary" class="text-sm text-gray-500">{{ $item->nutrients_summary }}</div>
</span>
</li>
@elseif($item::class === \App\Models\RecipeSeparator::class)
</ul></div>
@if($item->text)
<h2 class="mt-3 font-bold">{{ $item->text }}</h2>
@else
<hr class="mt-3 lg:w-1/2" />
@endif
<div class="prose prose-lg">
<ul class="space-y-2">
@endif
@endforeach
</ul>
</div>