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; namespace App\Http\Controllers;
use App\Models\Food;
use App\Models\IngredientAmount; use App\Models\IngredientAmount;
use App\Models\Recipe; use App\Models\Recipe;
use App\Models\RecipeSeparator;
use App\Models\RecipeStep; use App\Models\RecipeStep;
use App\Rules\ArrayNotEmpty; use App\Rules\ArrayNotEmpty;
use App\Rules\StringIsDecimalOrFraction; use App\Rules\StringIsDecimalOrFraction;
@ -84,11 +84,10 @@ class RecipeController extends Controller
$ingredients = []; $ingredients = [];
if ($old = old('ingredients')) { if ($old = old('ingredients')) {
foreach ($old['id'] as $key => $ingredient_id) { foreach ($old['id'] as $key => $ingredient_id) {
if (empty($ingredient_id)) {
continue;
}
$ingredients[] = [ $ingredients[] = [
'original_key' => $old['original_key'][$key], 'type' => 'ingredient',
'key' => $old['key'][$key],
'weight' => $old['weight'][$key],
'amount' => $old['amount'][$key], 'amount' => $old['amount'][$key],
'unit' => $old['unit'][$key], 'unit' => $old['unit'][$key],
'ingredient_id' => $ingredient_id, 'ingredient_id' => $ingredient_id,
@ -101,7 +100,9 @@ class RecipeController extends Controller
else { else {
foreach ($recipe->ingredientAmounts as $key => $ingredientAmount) { foreach ($recipe->ingredientAmounts as $key => $ingredientAmount) {
$ingredients[] = [ $ingredients[] = [
'original_key' => $key, 'type' => 'ingredient',
'key' => $key,
'weight' => $ingredientAmount->weight,
'amount' => $ingredientAmount->amount_formatted, 'amount' => $ingredientAmount->amount_formatted,
'unit' => $ingredientAmount->unit, 'unit' => $ingredientAmount->unit,
'ingredient_id' => $ingredientAmount->ingredient_id, '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 = []; $steps = [];
if ($old = old('steps')) { if ($old = old('steps')) {
foreach ($old['step'] as $key => $step) { foreach ($old['step'] as $key => $step) {
@ -119,7 +142,7 @@ class RecipeController extends Controller
continue; continue;
} }
$steps[] = [ $steps[] = [
'original_key' => $old['original_key'][$key], 'key' => $old['key'][$key],
'step_default' => $step, 'step_default' => $step,
]; ];
} }
@ -127,7 +150,7 @@ class RecipeController extends Controller
else { else {
foreach ($recipe->steps as $key => $step) { foreach ($recipe->steps as $key => $step) {
$steps[] = [ $steps[] = [
'original_key' => $key, 'key' => $key,
'step_default' => $step->step, 'step_default' => $step->step,
]; ];
} }
@ -142,7 +165,7 @@ class RecipeController extends Controller
return view('recipes.edit') return view('recipes.edit')
->with('recipe', $recipe) ->with('recipe', $recipe)
->with('recipe_tags', $recipe_tags) ->with('recipe_tags', $recipe_tags)
->with('ingredients', $ingredients) ->with('ingredients_list', new Collection([...$ingredients, ...$separators]))
->with('steps', $steps) ->with('steps', $steps)
->with('ingredients_units', Nutrients::$units); ->with('ingredients_units', Nutrients::$units);
} }
@ -175,15 +198,24 @@ class RecipeController extends Controller
'ingredients.unit' => ['required', 'array'], 'ingredients.unit' => ['required', 'array'],
'ingredients.unit.*' => ['required_with:ingredients.id.*'], 'ingredients.unit.*' => ['required_with:ingredients.id.*'],
'ingredients.detail' => ['required', 'array'], 'ingredients.detail' => ['required', 'array'],
'ingredients.detail.*' => 'nullable|string', 'ingredients.detail.*' => ['nullable', 'string'],
'ingredients.id' => ['required', 'array', new ArrayNotEmpty], 'ingredients.id' => ['required', 'array', new ArrayNotEmpty],
'ingredients.id.*' => 'required_with:ingredients.amount.*|nullable', 'ingredients.id.*' => 'required_with:ingredients.amount.*|nullable',
'ingredients.type' => ['required', 'array', new ArrayNotEmpty], 'ingredients.type' => ['required', 'array', new ArrayNotEmpty],
'ingredients.type.*' => ['required_with:ingredients.id.*', 'nullable', new UsesIngredientTrait()], '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' => ['required', 'array', new ArrayNotEmpty],
'steps.step.*' => 'nullable|string', 'steps.step.*' => ['nullable', 'string'],
'steps.original_key' => 'nullable|array', 'steps.key' => ['nullable', 'array'],
]); ]);
// Validate that no ingredients are recursive. // Validate that no ingredients are recursive.
@ -208,53 +240,9 @@ class RecipeController extends Controller
try { try {
DB::transaction(function () use ($recipe, $input) { DB::transaction(function () use ($recipe, $input) {
$recipe->saveOrFail(); $recipe->saveOrFail();
$this->updateIngredients($recipe, $input);
// Delete any removed ingredients. $this->updateIngredientSeparators($recipe, $input);
$removed = array_diff($recipe->ingredientAmounts->keys()->all(), $input['ingredients']['original_key']); $this->updateSteps($recipe, $input);
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);
}); });
} catch (\Exception $e) { } catch (\Exception $e) {
DB::rollBack(); DB::rollBack();
@ -289,6 +277,114 @@ class RecipeController extends Controller
return redirect()->route('recipes.show', $recipe); 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. * Confirm removal of specified resource.
*/ */

View File

@ -72,6 +72,7 @@ use Spatie\Tags\HasTags;
* @method static \Illuminate\Database\Eloquent\Builder|Food withAnyTagsOfAnyType($tags) * @method static \Illuminate\Database\Eloquent\Builder|Food withAnyTagsOfAnyType($tags)
* @mixin \Eloquent * @mixin \Eloquent
* @method static \Illuminate\Database\Eloquent\Builder|Food withUniqueSlugConstraints(\Illuminate\Database\Eloquent\Model $model, string $attribute, array $config, string $slug) * @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 final class Food extends Model
{ {

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ use ElasticScoutDriverPlus\QueryDsl;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
use Laravel\Scout\Searchable; use Laravel\Scout\Searchable;
use Spatie\Image\Manipulations; use Spatie\Image\Manipulations;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
@ -69,6 +70,11 @@ use Spatie\Tags\HasTags;
* @method static \Illuminate\Database\Eloquent\Builder|Recipe whereDescriptionDelta($value) * @method static \Illuminate\Database\Eloquent\Builder|Recipe whereDescriptionDelta($value)
* @property-read \Spatie\MediaLibrary\MediaCollections\Models\Collections\MediaCollection|Media[] $media * @property-read \Spatie\MediaLibrary\MediaCollections\Models\Collections\MediaCollection|Media[] $media
* @property-read int|null $media_count * @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 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); 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. * Get the steps for this Recipe.
*/ */
@ -165,6 +181,19 @@ final class Recipe extends Model implements HasMedia
return $this->hasMany(RecipeStep::class)->orderBy('number'); 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. * 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 whereStep($value)
* @method static \Illuminate\Database\Eloquent\Builder|RecipeStep whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|RecipeStep whereUpdatedAt($value)
* @mixin \Eloquent * @mixin \Eloquent
* @method static \Database\Factories\RecipeStepFactory factory(...$parameters)
*/ */
final class RecipeStep extends Model final class RecipeStep extends Model
{ {
@ -40,7 +41,7 @@ final class RecipeStep extends Model
]; ];
/** /**
* The attributes that should be cast. * @inheritdoc
*/ */
protected $casts = [ protected $casts = [
'number' => 'int', 'number' => 'int',

View File

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

View File

@ -42,6 +42,8 @@ class ArrayFormat
* *
* @return array * @return array
* The flipped array. * The flipped array.
*
* @todo Return Collection instead of array.
*/ */
public static function flipTwoDimensionalKeys(array $array): array { public static function flipTwoDimensionalKeys(array $array): array {
$flipped = []; $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": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@shopify/draggable": "^1.0.0-beta.8", "@shopify/draggable": "^1.0.0-beta.12",
"alpine-magic-helpers": "^1.1.0" "alpine-magic-helpers": "^1.1.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.2.1", "@tailwindcss/forms": "^0.2.1",
"@tailwindcss/typography": "^0.4.0", "@tailwindcss/typography": "^0.4.0",
"alpinejs": "^2.8.1", "alpinejs": "^2.8.2",
"autoprefixer": "^10.2.5", "autoprefixer": "^10.2.5",
"axios": "^0.21.1", "axios": "^0.21.1",
"cross-env": "^7.0", "cross-env": "^7.0",
@ -1138,9 +1138,9 @@
} }
}, },
"node_modules/@shopify/draggable": { "node_modules/@shopify/draggable": {
"version": "1.0.0-beta.8", "version": "1.0.0-beta.12",
"resolved": "https://registry.npmjs.org/@shopify/draggable/-/draggable-1.0.0-beta.8.tgz", "resolved": "https://registry.npmjs.org/@shopify/draggable/-/draggable-1.0.0-beta.12.tgz",
"integrity": "sha512-9IeBPQM93Ad4qFKUopwuTClzoST/1OId4MaSd/8FB5ScCL2tl25UaOGNR8E2hjiL7xK4LN5+I1Ews6amS7YAiA==" "integrity": "sha512-Un/Dn61sv2er9yjDXLGWMauCOWBb0BMbm0yzmmrD+oUX2/x50yhNJASTsCRdndUCpWlqYfZH8jEfaOgTPsKc/g=="
}, },
"node_modules/@tailwindcss/forms": { "node_modules/@tailwindcss/forms": {
"version": "0.2.1", "version": "0.2.1",
@ -2044,9 +2044,9 @@
} }
}, },
"node_modules/alpinejs": { "node_modules/alpinejs": {
"version": "2.8.1", "version": "2.8.2",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-2.8.1.tgz", "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-2.8.2.tgz",
"integrity": "sha512-ETJ/k0fbiBeP+OSd5Fhj70c+zb+YRzcVbyh5DVeLT3FBWMUeUvjOSWLi53IVLPSehaT2SKmB7w08WGF2jYTqNA==" "integrity": "sha512-5yOUtckn4CBp0qsHpo2qgjZyZit84uXvHbB7NJ27sn4FA6UlFl2i9PGUAdTXkcbFvvxDJBM+zpOD8RuNYFvQAw=="
}, },
"node_modules/ansi-colors": { "node_modules/ansi-colors": {
"version": "4.1.1", "version": "4.1.1",
@ -13684,9 +13684,9 @@
} }
}, },
"@shopify/draggable": { "@shopify/draggable": {
"version": "1.0.0-beta.8", "version": "1.0.0-beta.12",
"resolved": "https://registry.npmjs.org/@shopify/draggable/-/draggable-1.0.0-beta.8.tgz", "resolved": "https://registry.npmjs.org/@shopify/draggable/-/draggable-1.0.0-beta.12.tgz",
"integrity": "sha512-9IeBPQM93Ad4qFKUopwuTClzoST/1OId4MaSd/8FB5ScCL2tl25UaOGNR8E2hjiL7xK4LN5+I1Ews6amS7YAiA==" "integrity": "sha512-Un/Dn61sv2er9yjDXLGWMauCOWBb0BMbm0yzmmrD+oUX2/x50yhNJASTsCRdndUCpWlqYfZH8jEfaOgTPsKc/g=="
}, },
"@tailwindcss/forms": { "@tailwindcss/forms": {
"version": "0.2.1", "version": "0.2.1",
@ -14494,9 +14494,9 @@
} }
}, },
"alpinejs": { "alpinejs": {
"version": "2.8.1", "version": "2.8.2",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-2.8.1.tgz", "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-2.8.2.tgz",
"integrity": "sha512-ETJ/k0fbiBeP+OSd5Fhj70c+zb+YRzcVbyh5DVeLT3FBWMUeUvjOSWLi53IVLPSehaT2SKmB7w08WGF2jYTqNA==" "integrity": "sha512-5yOUtckn4CBp0qsHpo2qgjZyZit84uXvHbB7NJ27sn4FA6UlFl2i9PGUAdTXkcbFvvxDJBM+zpOD8RuNYFvQAw=="
}, },
"ansi-colors": { "ansi-colors": {
"version": "4.1.1", "version": "4.1.1",

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@
<x-slot name="header"> <x-slot name="header">
<h1 class="font-semibold text-xl text-gray-800 leading-tight">{{ $title }}</h1> <h1 class="font-semibold text-xl text-gray-800 leading-tight">{{ $title }}</h1>
</x-slot> </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 @if ($recipe->exists)@method('put')@endif
@csrf @csrf
<div class="flex flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0"> <div class="flex flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0">
@ -126,19 +126,29 @@
<!-- Ingredients --> <!-- Ingredients -->
<h3 class="mt-6 mb-2 font-extrabold text-lg">Ingredients</h3> <h3 class="mt-6 mb-2 font-extrabold text-lg">Ingredients</h3>
<div x-data class="ingredients space-y-4"> <div x-data class="ingredients space-y-4">
@forelse($ingredients as $ingredient) @forelse($ingredients_list->sortBy('weight') as $item)
@include('recipes.partials.ingredient-input', $ingredient) @if($item['type'] === 'ingredient')
@include('recipes.partials.ingredient-input', $item)
@elseif($item['type'] === 'separator')
@include('recipes.partials.separator-input', $item)
@endif
@empty @empty
@include('recipes.partials.ingredient-input') @include('recipes.partials.ingredient-input')
@endforelse @endforelse
<div class="entry-template hidden"> <div class="templates hidden">
@include('recipes.partials.ingredient-input') <div class="ingredient-template">
@include('recipes.partials.ingredient-input')
</div>
<div class="separator-template">
@include('recipes.partials.separator-input')
</div>
</div> </div>
<x-inputs.icon-button type="button" color="green" x-on:click="addEntryNode($el);"> <x-inputs.button type="button" color="green" x-on:click="addNodeFromTemplate($el, 'ingredient');">
<svg class="h-10 w-10 pointer-events-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> Add Ingredient
<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" /> </x-inputs.button>
</svg> <x-inputs.button type="button" color="blue" x-on:click="addNodeFromTemplate($el, 'separator');">
</x-inputs.icon-button> Add Separator
</x-inputs.button>
</div> </div>
<!-- Steps --> <!-- Steps -->
@ -149,18 +159,18 @@
@empty @empty
@include('recipes.partials.step-input') @include('recipes.partials.step-input')
@endforelse @endforelse
<div class="entry-template hidden"> <div class="templates hidden">
@include('recipes.partials.step-input') <div class="step-template">
@include('recipes.partials.step-input')
</div>
</div> </div>
<x-inputs.icon-button type="button" color="green" x-on:click="addEntryNode($el);"> <x-inputs.button type="button" color="green" x-on:click="addNodeFromTemplate($el, 'step');">
<svg class="h-10 w-10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> Add Step
<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" /> </x-inputs.button>
</svg>
</x-inputs.icon-button>
</div> </div>
<div x-data class="flex items-center justify-end mt-4"> <div class="flex items-center justify-end mt-4">
<x-inputs.button x-on:click="prepareForm();" class="ml-3"> <x-inputs.button x-on:click="prepareForm($el);" class="ml-3">
{{ ($recipe->exists ? 'Save' : 'Add') }} {{ ($recipe->exists ? 'Save' : 'Add') }}
</x-inputs.button> </x-inputs.button>
</div> </div>
@ -202,9 +212,9 @@
description.setContents(JSON.parse(document.querySelector('input[name="description_delta"]').value)); description.setContents(JSON.parse(document.querySelector('input[name="description_delta"]').value));
} catch (e) {} } catch (e) {}
// Activate ingredient draggables. // Activate ingredient sortable.
new Draggable.Sortable(document.querySelector('.ingredients'), { const ingredientsSortable = new Draggable.Sortable(document.querySelector('.ingredients'), {
draggable: '.ingredient', draggable: '.draggable',
handle: '.draggable-handle', handle: '.draggable-handle',
mirror: { mirror: {
appendTo: '.ingredients', 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. // Activate step draggables.
new Draggable.Sortable(document.querySelector('.steps'), { new Draggable.Sortable(document.querySelector('.steps'), {
draggable: '.step', draggable: '.draggable',
handle: '.draggable-handle', handle: '.draggable-handle',
mirror: { mirror: {
appendTo: '.steps', appendTo: '.steps',
@ -224,29 +243,39 @@
</script> </script>
<script type="text/javascript"> <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) => { let addNodeFromTemplate = ($el, type) => {
// Create clone of template entry. // Create clone of relevant template.
let template = $el.querySelector(':scope .entry-template'); const templates = $el.querySelector(`:scope .templates`);
let newEntry = template.cloneNode(true).firstElementChild; const template = templates.querySelector(`:scope .${type}-template`);
const newNode = template.cloneNode(true).firstElementChild;
// Insert new entry before add button. // Set weight based on previous sibling.
$el.insertBefore(newEntry, template); 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. * Prepare form values for submit.
*
* @param {object} $el Form element.
*/ */
let prepareForm = () => { let prepareForm = ($el) => {
// Remove any hidden templates before form submit. // 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. // Add description values to hidden fields.
document.querySelector('input[name="description_delta"]').value = JSON.stringify(description.getContents()); $el.querySelector('input[name="description_delta"]').value = JSON.stringify(description.getContents());
document.querySelector('input[name="description"]').value = description.root.innerHTML $el.querySelector('input[name="description"]').value = description.root.innerHTML
// Remove extraneous spaces from rendered result. // Remove extraneous spaces from rendered result.
.replaceAll('<p><br></p>', ''); .replaceAll('<p><br></p>', '');
} }

View File

@ -1,5 +1,6 @@
<div class="ingredient"> <div class="ingredient draggable">
<x-inputs.input type="hidden" name="ingredients[original_key][]" :value="$original_key ?? null" /> <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 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="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"> <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"> <div class="step draggable">
<x-inputs.input type="hidden" name="steps[original_key][]" :value="$original_key ?? null" /> <x-inputs.input type="hidden" name="steps[key][]" :value="$key ?? null" />
<div class="flex flex-row mb-4 space-x-4"> <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"> <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"> <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> </h1>
<div class="prose prose-lg"> <div class="prose prose-lg">
<ul class="space-y-2"> <ul class="space-y-2">
@foreach($recipe->ingredientAmounts as $ia) @foreach($recipe->ingredientsList->sortBy('weight') as $item)
<li> @if($item::class === \App\Models\IngredientAmount::class)
<span> <li>
{{ \App\Support\Number::fractionStringFromFloat($ia->amount) }} <span>
@if($ia->unitFormatted){{ $ia->unitFormatted }}@endif {{ \App\Support\Number::fractionStringFromFloat($item->amount) }}
@if($ia->ingredient->type === \App\Models\Recipe::class) @if($item->unitFormatted){{ $item->unitFormatted }}@endif
<a class="text-gray-500 hover:text-gray-700 hover:border-gray-300" @if($item->ingredient->type === \App\Models\Recipe::class)
href="{{ route('recipes.show', $ia->ingredient) }}"> <a class="text-gray-500 hover:text-gray-700 hover:border-gray-300"
{{ $ia->ingredient->name }} href="{{ route('recipes.show', $item->ingredient) }}">
</a> {{ $item->ingredient->name }}
@else </a>
{{ $ia->ingredient->name }}@if($ia->ingredient->detail), {{ $ia->ingredient->detail }}@endif @else
@endif {{ $item->ingredient->name }}@if($item->ingredient->detail), {{ $item->ingredient->detail }}@endif
@if($ia->detail)<span class="text-gray-500">{{ $ia->detail }}</span>@endif @endif
<div x-show="showNutrientsSummary" class="text-sm text-gray-500">{{ $ia->nutrients_summary }}</div> @if($item->detail)<span class="text-gray-500">{{ $item->detail }}</span>@endif
</span> <div x-show="showNutrientsSummary" class="text-sm text-gray-500">{{ $item->nutrients_summary }}</div>
</li> </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 @endforeach
</ul> </ul>
</div> </div>