Add support for meals customization (#15)

* Add meals as JSON field on User

* Change journal entry meal field to integer

* Make Quill and Draggable more modular

* Add meals routes and controller (WIP)

* Set default meals during migration

* Handle meals form

* No-op meal field migrations

This allows tests to pass easier and the migrations are not entirely
necessary anyway.

* Update factories and tests for new meals format

* Add basic meals edit test

* Update journal entry processing for new meal values

* Remove temporary migrations

* Only use default enabled meals in tests

* Add User `meals_enabled` attribute

* Ensure NULL values are removed from attributes

* Add meals data to use API response

* Set default meals on user create
This commit is contained in:
Christopher Charbonneau Wells 2021-05-30 13:11:58 -07:00 committed by GitHub
parent c15f81ee6b
commit 1fbb9a7dae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 277 additions and 44 deletions

View File

@ -122,7 +122,6 @@ class JournalEntryController extends Controller
return view('journal-entries.create')
->with('ingredients', $ingredients)
->with('meals', JournalEntry::meals()->toArray())
->with('units', Nutrients::units()->toArray())
->with('default_date', Carbon::createFromFormat('Y-m-d', $date));
}
@ -134,7 +133,6 @@ class JournalEntryController extends Controller
{
$date = $request->date ?? Carbon::now()->toDateString();
return view('journal-entries.create-from-nutrients')
->with('meals', JournalEntry::meals()->toArray())
->with('units', Nutrients::units()->toArray())
->with('default_date', Carbon::createFromFormat('Y-m-d', $date));
}
@ -279,8 +277,9 @@ class JournalEntryController extends Controller
*/
public function storeFromNutrients(StoreFromNutrientsJournalEntryRequest $request): RedirectResponse {
$attributes = $request->validated();
$entry = JournalEntry::make(array_filter($attributes))
->user()->associate(Auth::user());
$entry = JournalEntry::make(array_filter($attributes, function ($value) {
return !is_null($value);
}))->user()->associate(Auth::user());
$entry->save();
session()->flash('message', "Journal entry added!");
return redirect()->route(

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers;
use App\Rules\ArrayNotEmpty;
use App\Support\ArrayFormat;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class MealsController extends Controller
{
/**
* Show the form for editing a user's meals data.
*/
public function edit(): View
{
return view('meals.edit')->with('meals', Auth::user()->meals);
}
/**
* Update the user profile data.
*/
public function update(Request $request): RedirectResponse
{
$attributes = $request->validate([
'meal' => ['required', new ArrayNotEmpty],
'meal.value.*' => ['required', 'numeric'],
'meal.weight.*' => ['required', 'numeric'],
'meal.label.*' => ['nullable', 'string'],
'meal.enabled.*' => ['required', 'boolean'],
]);
$user = Auth::user();
$user->meals = ArrayFormat::flipTwoDimensionalKeys($attributes['meal']);
$user->save();
session()->flash('message', "Meals customizations updated!");
return redirect()->route('meals.edit');
}
}

View File

@ -2,12 +2,9 @@
namespace App\Http\Requests;
use App\Models\JournalEntry;
use App\Rules\ArrayNotEmpty;
use App\Rules\InArray;
use App\Rules\StringIsPositiveDecimalOrFraction;
use App\Rules\UsesIngredientTrait;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
class StoreFromNutrientsJournalEntryRequest extends FormRequest
{
@ -21,8 +18,7 @@ class StoreFromNutrientsJournalEntryRequest extends FormRequest
'date' => ['required', 'date'],
'meal' => [
'required',
'string',
new InArray(JournalEntry::meals()->pluck('value')->toArray())
new InArray(Auth::user()->meals_enabled->pluck('value')->toArray())
],
'summary' => ['required', 'string'],
'calories' => ['nullable', 'numeric', 'min:0', 'required_without_all:fat,cholesterol,sodium,carbohydrates,protein'],

View File

@ -8,6 +8,7 @@ use App\Rules\InArray;
use App\Rules\StringIsPositiveDecimalOrFraction;
use App\Rules\UsesIngredientTrait;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
class StoreJournalEntryRequest extends FormRequest
{
@ -22,10 +23,9 @@ class StoreJournalEntryRequest extends FormRequest
'ingredients.date.*' => ['nullable', 'date', 'required_with:ingredients.id.*'],
'ingredients.meal' => ['required', 'array', new ArrayNotEmpty],
'ingredients.meal.*' => [
'nullable',
'string',
'required',
'required_with:ingredients.id.*',
new InArray(JournalEntry::meals()->pluck('value')->toArray())
new InArray(Auth::user()->meals_enabled->pluck('value')->toArray())
],
'ingredients.amount' => ['required', 'array', new ArrayNotEmpty],
'ingredients.amount.*' => ['required_with:ingredients.id.*', 'nullable', new StringIsPositiveDecimalOrFraction],

View File

@ -29,6 +29,7 @@ class UserSchema extends SchemaProvider
return [
'username' => $resource->username,
'name' => $resource->name,
'meals' => $resource->meals,
'createdAt' => $resource->created_at,
'updatedAt' => $resource->updated_at,
];

View File

@ -3,11 +3,13 @@
namespace App\Models;
use App\Models\Traits\Sluggable;
use Illuminate\Database\Eloquent\Casts\AsCollection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
@ -51,6 +53,9 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* @mixin \Eloquent
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\JournalDate[] $journalDates
* @property-read int|null $journal_dates_count
* @property \Illuminate\Support\Collection|null $meals
* @method static \Illuminate\Database\Eloquent\Builder|User whereMeals($value)
* @property-read Collection $meals_enabled
*/
final class User extends Authenticatable implements HasMedia
{
@ -59,6 +64,16 @@ final class User extends Authenticatable implements HasMedia
use Notifiable;
use Sluggable;
/**
* @inheritdoc
*/
protected static function booted(): void {
static::creating(function (User $user) {
// Set default meals configuration.
$user->meals = User::getDefaultMeals();
});
}
/**
* @inheritdoc
*/
@ -66,6 +81,7 @@ final class User extends Authenticatable implements HasMedia
'username',
'password',
'name',
'meals',
'admin',
];
@ -82,8 +98,25 @@ final class User extends Authenticatable implements HasMedia
*/
protected $casts = [
'admin' => 'bool',
'meals' => AsCollection::class,
];
/**
* Get the default meals structure.
*/
public static function getDefaultMeals(): Collection {
$meals = new Collection();
for ($i = 0; $i <= 7; $i++) {
$meals->add([
'value' => $i,
'label' => 'Meal ' . ($i + 1),
'weight' => $i,
'enabled' => $i < 3,
]);
}
return $meals;
}
/**
* @inheritdoc
*/
@ -113,6 +146,13 @@ final class User extends Authenticatable implements HasMedia
return $this->hasMany(JournalEntry::class);
}
/**
* Get the User's enabled meals, sorted by weight.
*/
public function getMealsEnabledAttribute(): Collection {
return $this->meals->where('enabled', true)->sortBy('weight');
}
/**
* Get user's goal (if one exists) for a specific date.
*

View File

@ -11,7 +11,10 @@ class ArrayNotEmpty implements Rule
*/
public function passes($attribute, $value): bool
{
return !empty(array_filter($value));
return !empty(array_filter($value, function ($value) {
// Allow other "empty-y" values like false and 0.
return $value !== null;
}));
}
/**

View File

@ -29,7 +29,7 @@ class JournalEntryFactory extends Factory
'sodium' => $this->faker->randomFloat(1, 0, 500),
'carbohydrates' => $this->faker->randomFloat(1, 0, 40),
'protein' => $this->faker->randomFloat(1, 0, 20),
'meal' => $this->faker->randomElement(['breakfast', 'lunch', 'dinner', 'snacks']),
'meal' => User::getDefaultMeals()->where('enabled', true)->pluck('value')->random(),
];
}

View File

@ -19,6 +19,7 @@ class CreateUsersTable extends Migration
$table->string('username')->unique();
$table->string('password');
$table->string('name');
$table->json('meals')->nullable();
$table->boolean('admin')->default(false);
$table->rememberToken();
$table->timestamps();

View File

@ -26,7 +26,7 @@ class CreateJournalEntriesTable extends Migration
$table->unsignedFloat('sodium')->default(0);
$table->unsignedFloat('carbohydrates')->default(0);
$table->unsignedFloat('protein')->default(0);
$table->enum('meal', JournalEntry::meals()->pluck('value')->toArray());
$table->unsignedInteger('meal');
$table->timestamps();
});
}

2
public/css/app.css vendored

File diff suppressed because one or more lines are too long

1
public/js/draggable.js vendored Normal file

File diff suppressed because one or more lines are too long

2
public/js/quill.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,15 @@
/*!
* Quill Editor v1.3.7
* https://quilljs.com/
* Copyright (c) 2014, Jason Chen
* Copyright (c) 2013, salesforce.com
*/
/*!
* The buffer module from node.js, for the browser.
*
* @author Feross Aboukhadijeh <http://feross.org>
* @license MIT
*/
/*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh <https://feross.org/opensource> */

2
public/js/quill.module.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,8 @@
/*!
* The buffer module from node.js, for the browser.
*
* @author Feross Aboukhadijeh <http://feross.org>
* @license MIT
*/
/*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh <https://feross.org/opensource> */

View File

@ -1,6 +1,7 @@
{
"/js/app.js": "/js/app.js",
"/js/recipes/edit.js": "/js/recipes/edit.js",
"/js/draggable.js": "/js/draggable.js",
"/js/quill.js": "/js/quill.js",
"/css/recipes/edit.css": "/css/recipes/edit.css",
"/css/app.css": "/css/app.css"
}

1
resources/js/draggable.js vendored Normal file
View File

@ -0,0 +1 @@
window.Draggable = require('@shopify/draggable');

13
resources/js/quill.js vendored
View File

@ -1,11 +1,2 @@
import Quill from 'quill/core';
import Toolbar from 'quill/modules/toolbar';
import Snow from 'quill/themes/snow';
Quill.register({
'modules/toolbar': Toolbar,
'themes/snow': Snow,
});
export default Quill;
require('./quill.module');
window.Quill = require('quill');

11
resources/js/quill.module.js vendored Normal file
View File

@ -0,0 +1,11 @@
import Quill from 'quill/core';
import Toolbar from 'quill/modules/toolbar';
import Snow from 'quill/themes/snow';
Quill.register({
'modules/toolbar': Toolbar,
'themes/snow': Snow,
});
export default Quill;

View File

@ -1,4 +0,0 @@
require('../quill');
window.Draggable = require('@shopify/draggable');
window.Quill = require('quill');

View File

@ -29,7 +29,7 @@
<x-inputs.select name="meal"
class="block w-full"
:options="$meals"
:options="Auth::user()->meals_enabled->toArray()"
:selectedValue="old('meal')"
:hasError="$errors->has('meal')"
required>

View File

@ -20,7 +20,7 @@
</div>
<div>
<span class="font-bold">Meal:</span>
<span>{{ $journal_entry->meal }}</span>
<span>{{ \Illuminate\Support\Facades\Auth::user()->meals->firstWhere('value', $journal_entry->meal)['label'] }}</span>
</div>
</div>
<x-inputs.button class="bg-red-800 hover:bg-red-700">

View File

@ -135,21 +135,21 @@
</section>
</div>
<div class="w-full sm:w-3/5 md:w-2/3 lg:w-3/4 flex flex-col space-y-4">
@foreach(['breakfast', 'lunch', 'dinner', 'snacks'] as $meal)
@foreach(\Illuminate\Support\Facades\Auth::user()->meals_enabled as $meal)
<div>
<h3 class="font-semibold text-lg text-gray-800">
<div class="flex items-center">
<div>{{ Str::ucfirst($meal) }}</div>
<div class="ml-2 w-full"><hr/></div>
<div>{{ $meal['label'] }}</div>
<div class="ml-2 flex-grow"><hr/></div>
</div>
<span class="text-sm text-gray-500">
@foreach(\App\Support\Nutrients::all()->sortBy('weight') as $nutrient)
{{ \App\Support\Nutrients::round($entries->where('meal', $meal)->sum($nutrient['value']), $nutrient['value']) }}{{ $nutrient['unit'] }}
{{ \App\Support\Nutrients::round($entries->where('meal', $meal['value'])->sum($nutrient['value']), $nutrient['value']) }}{{ $nutrient['unit'] }}
{{ $nutrient['value'] }}@if(!$loop->last), @endif
@endforeach
</span>
</h3>
@forelse($entries->where('meal', $meal) as $entry)
@forelse($entries->where('meal', $meal['value']) as $entry)
<details>
<summary>{{ $entry->summary }}</summary>
<div class="border-blue-100 border-2 p-2 ml-4">

View File

@ -25,7 +25,7 @@
<x-inputs.label for="ingredients[meal][]" value="Meal" class="md:hidden"/>
<x-inputs.select name="ingredients[meal][]"
class="block w-full"
:options="$meals"
:options="Auth::user()->meals_enabled->toArray()"
:selectedValue="$meal ?? null"
:hasError="$errors->has('ingredients.meal.' . $key)"
required>

View File

@ -29,6 +29,7 @@
<div class="space-y-2">
<x-dropdown-link :href="route('profiles.show', Auth::user())">My Profile</x-dropdown-link>
<x-dropdown-link :href="route('goals.index')">My Goals</x-dropdown-link>
<x-dropdown-link :href="route('meals.edit')">My Meals</x-dropdown-link>
@can('administer', \App\Models\User::class)
<hr />
<x-dropdown-link :href="route('users.index')">Manage Users</x-dropdown-link>

View File

@ -0,0 +1,76 @@
<x-app-layout>
<x-slot name="title">My Meals</x-slot>
<x-slot name="header">
<h1 class="font-semibold text-xl text-gray-800 leading-tight">My Meals</h1>
</x-slot>
<form method="POST" enctype="multipart/form-data" action="{{ route('meals.update') }}">
@method('put')
@csrf
<div class="flex flex-col space-y-4">
<div class="flex flex-row space-x-4 w-full items-center bg-gray-200 text-gray-600 uppercase text-sm leading-normal font-bold">
<div class="w-1/6 sm:w-1/12 p-2">&nbsp;</div>
<div class="w-4/6 sm:w-5/6 p-2">Meal name</div>
<div class="w-1/6 sm:w-1/12 p-2 text-center">Active</div>
</div>
<div x-data class="meals space-y-4">
@foreach($meals as $key => $meal)
<div class="meal draggable w-full">
<x-inputs.input type="hidden" name="meal[value][]" :value="$meal['value']" />
<x-inputs.input type="hidden" name="meal[weight][]" :value="$meal['weight'] ?? null" />
<div class="flex flex-row space-x-4 w-full items-center">
<div class="w-1/6 sm:w-1/12">
<div class="draggable-handle self-center text-gray-500 bg-gray-100 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>
</div>
<x-inputs.input name="meal[label][]"
type="text"
size="5"
placeholder="Breakfast, lunch, dinner, etc."
class="block w-4/6 sm:w-5/6"
:value="$meal['label'] ?? null" />
<div class="w-1/6 sm:w-1/12 text-center">
<x-inputs.input name="meal[enabled][]"
type="checkbox"
value="1"
:checked="$meal['enabled'] ?? null" />
</div>
</div>
</div>
@endforeach
</div>
</div>
<div class="flex items-center justify-end mt-4">
<x-inputs.button>Save</x-inputs.button>
</div>
</form>
@once
@push('scripts')
<script src="{{ asset('js/draggable.js') }}"></script>
<script type="text/javascript">
// Activate meals sortable.
const mealsSortable = new Draggable.Sortable(document.querySelector('.meals'), {
draggable: '.draggable',
handle: '.draggable-handle',
mirror: {
appendTo: '.meals',
constrainDimensions: true,
},
})
// Recalculate weight (order) of all ingredients.
mealsSortable.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;
});
})
</script>
@endpush
@endonce
</x-app-layout>

View File

@ -182,7 +182,8 @@
@once
@push('scripts')
<script src="{{ asset('js/recipes/edit.js') }}"></script>
<script src="{{ asset('js/draggable.js') }}"></script>
<script src="{{ asset('js/quill.js') }}"></script>
<script type="text/javascript">
// Enforce inline (style-base) alignment.

View File

@ -5,6 +5,7 @@ use App\Http\Controllers\GoalController;
use App\Http\Controllers\IngredientPickerController;
use App\Http\Controllers\JournalDateController;
use App\Http\Controllers\JournalEntryController;
use App\Http\Controllers\MealsController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\RecipeController;
use App\Http\Controllers\Auth\AuthenticatedSessionController;
@ -40,6 +41,10 @@ Route::middleware(['auth'])->group(function () {
Route::resource('journal-entries', JournalEntryController::class);
Route::get('/journal-entries/{journal_entry}/delete', [JournalEntryController::class, 'delete'])->name('journal-entries.delete');
// Meals.
Route::get('/meals', [MealsController::class, 'edit'])->name('meals.edit');
Route::put('/meals', [MealsController::class, 'update'])->name('meals.update');
// Recipes.
Route::resource('recipes', RecipeController::class);
Route::get('/recipes/{recipe}/delete', [RecipeController::class, 'delete'])->name('recipes.delete');

View File

@ -5,6 +5,7 @@ namespace Tests\Feature\Http\Controllers;
use App\Http\Controllers\JournalEntryController;
use App\Models\IngredientAmount;
use App\Models\JournalEntry;
use App\Models\User;
use Database\Factories\JournalEntryFactory;
use Illuminate\Foundation\Testing\WithFaker;
@ -130,7 +131,7 @@ class JournalEntryControllerTest extends HttpControllerTestCase
/** @var \App\Models\IngredientAmount $ingredient_amount */
foreach ($ingredient_amounts as $ingredient_amount) {
$ingredients['date'][] = $this->faker->dateTimeThisMonth->format('Y-m-d');
$ingredients['meal'][] = $this->faker->randomElement(JournalEntry::meals()->pluck('value')->toArray());
$ingredients['meal'][] = $this->user->meals_enabled->pluck('value')->random();
$ingredients['name'][] = $ingredient_amount->ingredient->name;
$ingredients['amount'][] = $ingredient_amount->amount;
$ingredients['unit'][] = $ingredient_amount->unit;

View File

@ -0,0 +1,37 @@
<?php
namespace Tests\Feature\Http\Controllers;
use App\Http\Controllers\MealsController;
use Tests\LoggedInTestCase;
class MealsControllerTest extends LoggedInTestCase
{
/**
* Test editing meals.
*/
public function testCanEditMeals(): void
{
$edit_url = action([MealsController::class, 'edit']);
$response = $this->get($edit_url);
$response->assertOk();
$meal_data = [];
$this->user->meals->each(function (array $meal) use (&$meal_data) {
$meal_data['value'][] = $meal['value'];
$meal_data['weight'][] = $meal['weight'];
$meal_data['label'][] = "Updated {$meal['label']}";
$meal_data['enabled'][] = $meal['enabled'] ?? false;
});
$put_url = action([MealsController::class, 'update']);
$response = $this->put($put_url, ['meal' => $meal_data]);
$response->assertSessionHasNoErrors();
$this->user->refresh();
$this->user->meals->each(function (array $meal) {
$this->assertStringStartsWith('Updated', $meal['label']);
});
}
}

3
webpack.mix.js vendored
View File

@ -13,7 +13,8 @@ const mix = require('laravel-mix');
mix
.js('resources/js/app.js', 'public/js')
.js('resources/js/recipes/edit.js', 'public/js/recipes')
.js('resources/js/draggable.js', 'public/js')
.js('resources/js/quill.js', 'public/js')
.postCss('resources/css/app.css', 'public/css', [
require('postcss-import'),
require('tailwindcss'),