Implemented multiple/named shopping lists (closes #190)

This commit is contained in:
Bernd Bestel 2019-04-20 17:04:40 +02:00
parent c1674d33b4
commit cdd02efcc6
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
14 changed files with 397 additions and 123 deletions

View File

@ -242,13 +242,29 @@ class StockApiController extends BaseApiController
public function AddMissingProductsToShoppingList(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
$this->StockService->AddMissingProductsToShoppingList();
$requestBody = $request->getParsedBody();
$listId = 1;
if (array_key_exists('list_id', $requestBody) && !empty($requestBody['list_id']) && is_numeric($requestBody['list_id']))
{
$listId = intval($requestBody['list_id']);
}
$this->StockService->AddMissingProductsToShoppingList($listId);
return $this->EmptyApiResponse($response);
}
public function ClearShoppingList(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
$this->StockService->ClearShoppingList();
$requestBody = $request->getParsedBody();
$listId = 1;
if (array_key_exists('list_id', $requestBody) && !empty($requestBody['list_id']) && is_numeric($requestBody['list_id']))
{
$listId = intval($requestBody['list_id']);
}
$this->StockService->ClearShoppingList($listId);
return $this->EmptyApiResponse($response);
}

View File

@ -59,12 +59,20 @@ class StockController extends BaseController
public function ShoppingList(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
$listId = 1;
if (isset($request->getQueryParams()['list']))
{
$listId = $request->getQueryParams()['list'];
}
return $this->AppContainer->view->render($response, 'shoppinglist', [
'listItems' => $this->Database->shopping_list(),
'listItems' => $this->Database->shopping_list()->where('shopping_list_id = :1', $listId),
'products' => $this->Database->products()->orderBy('name'),
'quantityunits' => $this->Database->quantity_units()->orderBy('name'),
'missingProducts' => $this->StockService->GetMissingProducts(),
'productGroups' => $this->Database->product_groups()->orderBy('name')
'productGroups' => $this->Database->product_groups()->orderBy('name'),
'shoppingLists' => $this->Database->shopping_lists()->orderBy('name'),
'selectedShoppingListId' => $listId
]);
}
@ -187,14 +195,14 @@ class StockController extends BaseController
{
if ($args['itemId'] == 'new')
{
return $this->AppContainer->view->render($response, 'shoppinglistform', [
return $this->AppContainer->view->render($response, 'shoppinglistitemform', [
'products' => $this->Database->products()->orderBy('name'),
'mode' => 'create'
]);
}
else
{
return $this->AppContainer->view->render($response, 'shoppinglistform', [
return $this->AppContainer->view->render($response, 'shoppinglistitemform', [
'listItem' => $this->Database->shopping_list($args['itemId']),
'products' => $this->Database->products()->orderBy('name'),
'mode' => 'edit'
@ -202,6 +210,23 @@ class StockController extends BaseController
}
}
public function ShoppingListEditForm(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
if ($args['listId'] == 'new')
{
return $this->AppContainer->view->render($response, 'shoppinglistform', [
'mode' => 'create'
]);
}
else
{
return $this->AppContainer->view->render($response, 'shoppinglistform', [
'shoppingList' => $this->Database->shopping_lists($args['listId']),
'mode' => 'edit'
]);
}
}
public function Journal(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
return $this->AppContainer->view->render($response, 'stockjournal', [

View File

@ -1330,10 +1330,29 @@
},
"/stock/shoppinglist/add-missing-products": {
"post": {
"summary": "Adds currently missing products (below defined min. stock amount) to the shopping list",
"summary": "Adds currently missing products (below defined min. stock amount) to the given shopping list",
"tags": [
"Stock"
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"list_id": {
"type": "integer",
"description": "The shopping list to use, when omitted, the default shopping list (with id 1) is used"
}
},
"example": {
"list_id": 2
}
}
}
}
},
"responses": {
"204": {
"description": "The operation was successful"
@ -1343,10 +1362,29 @@
},
"/stock/shoppinglist/clear": {
"post": {
"summary": "Removes all items from the shopping list",
"summary": "Removes all items from the given shopping list",
"tags": [
"Stock"
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"list_id": {
"type": "integer",
"description": "The shopping list id to clear, when omitted, the default shopping list (with id 1) is used"
}
},
"example": {
"list_id": 2
}
}
}
}
},
"responses": {
"204": {
"description": "The operation was successful"
@ -1964,6 +2002,7 @@
"locations",
"quantity_units",
"shopping_list",
"shopping_lists",
"recipes",
"recipes_pos",
"recipes_nestings",

View File

@ -151,7 +151,7 @@ return array(
'Edit recipe ingredient' => 'Edit recipe ingredient',
'Are you sure to delete recipe "#1"?' => 'Are you sure to delete recipe "#1"?',
'Are you sure to delete recipe ingredient "#1"?' => 'Are you sure to delete recipe ingredient "#1"?',
'Are you sure to empty the shopping list?' => 'Are you sure to empty the shopping list?',
'Are you sure to empty shopping list "#1"?' => 'Are you sure to empty shopping list "#1"?',
'Clear list' => 'Clear list',
'Requirements fulfilled' => 'Requirements fulfilled',
'Put missing products on shopping list' => 'Put missing products on shopping list',

14
migrations/0062.sql Normal file
View File

@ -0,0 +1,14 @@
ALTER TABLE shopping_list
ADD shopping_list_id INT DEFAULT 1;
CREATE TABLE shopping_lists (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
name TEXT NOT NULL UNIQUE,
description TEXT,
row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime'))
);
INSERT INTO shopping_lists
(name)
VALUES
('Default');

13
migrations/0063.php Normal file
View File

@ -0,0 +1,13 @@
<?php
// This is executed inside DatabaseMigrationService class/context
use \Grocy\Services\LocalizationService;
$localizationService = new LocalizationService(GROCY_CULTURE);
$db = $this->DatabaseService->GetDbConnection();
$defaultShoppingList = $this->Database->shopping_lists()->where('id = 1')->fetch();
$defaultShoppingList->update(array(
'name' => $localizationService->Localize('Shopping list')
));

View File

@ -51,6 +51,12 @@ $("#status-filter").on("change", function()
shoppingListTable.column(4).search(value).draw();
});
$("#selected-shopping-list").on("change", function()
{
var value = $(this).val();
window.location.href = U('/shoppinglist?list=' + value);
});
$(".status-filter-button").on("click", function()
{
var value = $(this).data("status-filter");
@ -58,6 +64,42 @@ $(".status-filter-button").on("click", function()
$("#status-filter").trigger("change");
});
$("#delete-selected-shopping-list").on("click", function()
{
var objectName = $("#selected-shopping-list option:selected").text();
var objectId = $("#selected-shopping-list").val();
bootbox.confirm({
message: L('Are you sure to delete shopping list "#1"?', objectName),
buttons: {
confirm: {
label: L('Yes'),
className: 'btn-success'
},
cancel: {
label: L('No'),
className: 'btn-danger'
}
},
callback: function (result)
{
if (result === true)
{
Grocy.Api.Delete('objects/shopping_lists/' + objectId, {},
function (result)
{
window.location.href = U('/shoppinglist');
},
function (xhr)
{
console.error(xhr);
}
);
}
}
});
});
$(document).on('click', '.shoppinglist-delete-button', function(e)
{
e.preventDefault();
@ -85,10 +127,10 @@ $(document).on('click', '.shoppinglist-delete-button', function (e)
$(document).on('click', '#add-products-below-min-stock-amount', function(e)
{
Grocy.Api.Post('stock/shoppinglist/add-missing-products', { },
Grocy.Api.Post('stock/shoppinglist/add-missing-products', { "list_id": $("#selected-shopping-list").val() },
function(result)
{
window.location.href = U('/shoppinglist');
window.location.href = U('/shoppinglist?list=' + $("#selected-shopping-list").val());
},
function(xhr)
{
@ -100,7 +142,7 @@ $(document).on('click', '#add-products-below-min-stock-amount', function(e)
$(document).on('click', '#clear-shopping-list', function(e)
{
bootbox.confirm({
message: L('Are you sure to empty the shopping list?'),
message: L('Are you sure to empty shopping list "#1"?', $("#selected-shopping-list option:selected").text()),
buttons: {
confirm: {
label: L('Yes'),
@ -117,7 +159,7 @@ $(document).on('click', '#clear-shopping-list', function(e)
{
Grocy.FrontendHelpers.BeginUiBusy();
Grocy.Api.Post('stock/shoppinglist/clear', { },
Grocy.Api.Post('stock/shoppinglist/clear', { "list_id": $("#selected-shopping-list").val() },
function(result)
{
$('#shoppinglist-table tbody tr').fadeOut(500, function()

View File

@ -1,115 +1,61 @@
$('#save-shoppinglist-button').on('click', function(e)
$('#save-shopping-list-button').on('click', function(e)
{
e.preventDefault();
var jsonData = $('#shoppinglist-form').serializeJSON();
Grocy.FrontendHelpers.BeginUiBusy("shoppinglist-form");
var jsonData = $('#shopping-list-form').serializeJSON();
Grocy.FrontendHelpers.BeginUiBusy("shopping-list-form");
if (Grocy.EditMode === 'create')
{
Grocy.Api.Post('objects/shopping_list', jsonData,
Grocy.Api.Post('objects/shopping_lists', jsonData,
function(result)
{
window.location.href = U('/shoppinglist');
},
function(xhr)
{
Grocy.FrontendHelpers.EndUiBusy("shoppinglist-form");
console.error(xhr);
Grocy.FrontendHelpers.EndUiBusy("shopping-list-form");
Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response)
}
);
}
else
{
Grocy.Api.Put('objects/shopping_list/' + Grocy.EditObjectId, jsonData,
Grocy.Api.Put('objects/shopping_lists/' + Grocy.EditObjectId, jsonData,
function(result)
{
window.location.href = U('/shoppinglist');
},
function(xhr)
{
Grocy.FrontendHelpers.EndUiBusy("shoppinglist-form");
console.error(xhr);
Grocy.FrontendHelpers.EndUiBusy("shopping-list-form");
Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response)
}
);
}
});
Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
$('#shopping-list-form input').keyup(function(event)
{
var productId = $(e.target).val();
if (productId)
{
Grocy.Components.ProductCard.Refresh(productId);
Grocy.Api.Get('stock/products/' + productId,
function (productDetails)
{
$('#amount_qu_unit').text(productDetails.quantity_unit_purchase.name);
if (productDetails.product.allow_partial_units_in_stock == 1)
{
$("#amount").attr("min", "0.01");
$("#amount").attr("step", "0.01");
$("#amount").parent().find(".invalid-feedback").text(L('The amount cannot be lower than #1', 0.01.toLocaleString()));
}
else
{
$("#amount").attr("min", "1");
$("#amount").attr("step", "1");
$("#amount").parent().find(".invalid-feedback").text(L('The amount cannot be lower than #1', '1'));
}
$('#amount').focus();
Grocy.FrontendHelpers.ValidateForm('shoppinglist-form');
},
function(xhr)
{
console.error(xhr);
}
);
}
Grocy.FrontendHelpers.ValidateForm('shopping-list-form');
});
Grocy.FrontendHelpers.ValidateForm('shoppinglist-form');
Grocy.Components.ProductPicker.GetInputElement().focus();
if (Grocy.EditMode === "edit")
{
Grocy.Components.ProductPicker.GetPicker().trigger('change');
}
$('#amount').on('focus', function(e)
{
if (Grocy.Components.ProductPicker.GetValue().length === 0)
{
Grocy.Components.ProductPicker.GetInputElement().focus();
}
else
{
$(this).select();
}
});
$('#shoppinglist-form input').keyup(function (event)
{
Grocy.FrontendHelpers.ValidateForm('shoppinglist-form');
});
$('#shoppinglist-form input').keydown(function (event)
$('#shopping-list-form input').keydown(function (event)
{
if (event.keyCode === 13) //Enter
{
event.preventDefault();
if (document.getElementById('shoppinglist-form').checkValidity() === false) //There is at least one validation error
if (document.getElementById('shopping-list-form').checkValidity() === false) //There is at least one validation error
{
return false;
}
else
{
$('#save-shoppinglist-button').click();
$('#save-shopping-list-button').click();
}
}
});
$('#name').focus();
Grocy.FrontendHelpers.ValidateForm('shopping-list-form');

View File

@ -0,0 +1,116 @@
$('#save-shoppinglist-button').on('click', function(e)
{
e.preventDefault();
var jsonData = $('#shoppinglist-form').serializeJSON();
jsonData.shopping_list_id = GetUriParam("list");
Grocy.FrontendHelpers.BeginUiBusy("shoppinglist-form");
if (Grocy.EditMode === 'create')
{
Grocy.Api.Post('objects/shopping_list', jsonData,
function(result)
{
window.location.href = U('/shoppinglist?list=' + GetUriParam("list"));
},
function(xhr)
{
Grocy.FrontendHelpers.EndUiBusy("shoppinglist-form");
console.error(xhr);
}
);
}
else
{
Grocy.Api.Put('objects/shopping_list/' + Grocy.EditObjectId, jsonData,
function(result)
{
window.location.href = U('/shoppinglist?list=' + GetUriParam("list"));
},
function(xhr)
{
Grocy.FrontendHelpers.EndUiBusy("shoppinglist-form");
console.error(xhr);
}
);
}
});
Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
{
var productId = $(e.target).val();
if (productId)
{
Grocy.Components.ProductCard.Refresh(productId);
Grocy.Api.Get('stock/products/' + productId,
function (productDetails)
{
$('#amount_qu_unit').text(productDetails.quantity_unit_purchase.name);
if (productDetails.product.allow_partial_units_in_stock == 1)
{
$("#amount").attr("min", "0.01");
$("#amount").attr("step", "0.01");
$("#amount").parent().find(".invalid-feedback").text(L('The amount cannot be lower than #1', 0.01.toLocaleString()));
}
else
{
$("#amount").attr("min", "1");
$("#amount").attr("step", "1");
$("#amount").parent().find(".invalid-feedback").text(L('The amount cannot be lower than #1', '1'));
}
$('#amount').focus();
Grocy.FrontendHelpers.ValidateForm('shoppinglist-form');
},
function(xhr)
{
console.error(xhr);
}
);
}
});
Grocy.FrontendHelpers.ValidateForm('shoppinglist-form');
Grocy.Components.ProductPicker.GetInputElement().focus();
if (Grocy.EditMode === "edit")
{
Grocy.Components.ProductPicker.GetPicker().trigger('change');
}
$('#amount').on('focus', function(e)
{
if (Grocy.Components.ProductPicker.GetValue().length === 0)
{
Grocy.Components.ProductPicker.GetInputElement().focus();
}
else
{
$(this).select();
}
});
$('#shoppinglist-form input').keyup(function (event)
{
Grocy.FrontendHelpers.ValidateForm('shoppinglist-form');
});
$('#shoppinglist-form input').keydown(function (event)
{
if (event.keyCode === 13) //Enter
{
event.preventDefault();
if (document.getElementById('shoppinglist-form').checkValidity() === false) //There is at least one validation error
{
return false;
}
else
{
$('#save-shoppinglist-button').click();
}
}
});

View File

@ -41,6 +41,7 @@ $app->group('', function()
{
$this->get('/shoppinglist', '\Grocy\Controllers\StockController:ShoppingList');
$this->get('/shoppinglistitem/{itemId}', '\Grocy\Controllers\StockController:ShoppingListItemEditForm');
$this->get('/shoppinglist/{listId}', '\Grocy\Controllers\StockController:ShoppingListEditForm');
}
// Recipe routes

View File

@ -430,7 +430,7 @@ class StockService extends BaseService
return $this->Database->lastInsertId();
}
public function AddMissingProductsToShoppingList()
public function AddMissingProductsToShoppingList($listId = 1)
{
$missingProducts = $this->GetMissingProducts();
foreach ($missingProducts as $missingProduct)
@ -444,7 +444,8 @@ class StockService extends BaseService
if ($alreadyExistingEntry->amount < $amountToAdd)
{
$alreadyExistingEntry->update(array(
'amount' => $amountToAdd
'amount' => $amountToAdd,
'shopping_list_id' => $listId
));
}
}
@ -452,16 +453,17 @@ class StockService extends BaseService
{
$shoppinglistRow = $this->Database->shopping_list()->createRow(array(
'product_id' => $missingProduct->id,
'amount' => $amountToAdd
'amount' => $amountToAdd,
'shopping_list_id' => $listId
));
$shoppinglistRow->save();
}
}
}
public function ClearShoppingList()
public function ClearShoppingList($listId = 1)
{
$this->Database->shopping_list()->delete();
$this->Database->shopping_list()->where('shopping_list_id = :1', $listId)->delete();
}
private function ProductExists($productId)

View File

@ -20,8 +20,8 @@
<div class="col">
<h1>
@yield('title')
<a class="btn btn-outline-dark responsive-button" href="{{ $U('/shoppinglistitem/new') }}">
<i class="fas fa-plus"></i> {{ $L('Add') }}
<a class="btn btn-outline-dark responsive-button" href="{{ $U('/shoppinglistitem/new?list=' . $selectedShoppingListId) }}">
<i class="fas fa-plus"></i> {{ $L('Add item') }}
</a>
<a id="clear-shopping-list" class="btn btn-outline-danger responsive-button @if($listItems->count() == 0) disabled @endif" href="#">
<i class="fas fa-trash"></i> {{ $L('Clear list') }}
@ -37,6 +37,26 @@
</div>
</div>
<div class="row mt-3">
<div class="col-xs-12 col-md-4">
<label for="selected-shopping-list">{{ $L('Selected shopping list') }}</label>
<select class="form-control" id="selected-shopping-list">
@foreach($shoppingLists as $shoppingList)
<option @if($shoppingList->id == $selectedShoppingListId) selected="selected" @endif value="{{ $shoppingList->id }}">{{ $shoppingList->name }}</option>
@endforeach
</select>
</div>
<div class="col-xs-12 col-md-4">
<label for="selected-shopping-list">&nbsp;</label><br>
<a class="btn btn-outline-dark responsive-button" href="{{ $U('/shoppinglist/new') }}">
<i class="fas fa-plus"></i> {{ $L('New shopping list') }}
</a>
<a id="delete-selected-shopping-list" class="btn btn-outline-danger responsive-button @if($selectedShoppingListId == 1) disabled @endif" href="#">
<i class="fas fa-trash"></i> {{ $L('Delete shopping list') }}
</a>
</div>
</div>
<div class="row mt-3">
<div class="col-xs-12 col-md-4">
<label for="search">{{ $L('Search') }}</label> <i class="fas fa-search"></i>
@ -67,7 +87,7 @@
@foreach($listItems as $listItem)
<tr id="shoppinglistitem-{{ $listItem->id }}-row" class="@if(FindObjectInArrayByPropertyValue($missingProducts, 'id', $listItem->product_id) !== null) table-info @endif">
<td class="fit-content border-right">
<a class="btn btn-sm btn-info" href="{{ $U('/shoppinglistitem/') }}{{ $listItem->id }}">
<a class="btn btn-sm btn-info" href="{{ $U('/shoppinglistitem/') . $listItem->id . '?list=' . $selectedShoppingListId }}">
<i class="fas fa-edit"></i>
</a>
<a class="btn btn-sm btn-danger shoppinglist-delete-button" href="#" data-shoppinglist-id="{{ $listItem->id }}">

View File

@ -1,56 +1,40 @@
@extends('layout.default')
@if($mode == 'edit')
@section('title', $L('Edit shopping list item'))
@section('title', $L('Edit shopping list'))
@else
@section('title', $L('Create shopping list item'))
@section('title', $L('Create shopping list'))
@endif
@section('viewJsName', 'shoppinglistform')
@section('content')
<div class="row">
<div class="col-xs-12 col-md-6 col-xl-4 pb-3">
<div class="col-lg-6 col-xs-12">
<h1>@yield('title')</h1>
<script>Grocy.EditMode = '{{ $mode }}';</script>
@if($mode == 'edit')
<script>Grocy.EditObjectId = {{ $listItem->id }};</script>
<script>Grocy.EditObjectId = {{ $shoppingList->id }};</script>
@endif
<form id="shoppinglist-form" novalidate>
@php if($mode == 'edit') { $productId = $listItem->product_id; } else { $productId = ''; } @endphp
@include('components.productpicker', array(
'products' => $products,
'nextInputSelector' => '#amount',
'isRequired' => false,
'prefillById' => $productId
))
@php if($mode == 'edit') { $value = $listItem->amount; } else { $value = 1; } @endphp
@include('components.numberpicker', array(
'id' => 'amount',
'label' => 'Amount',
'hintId' => 'amount_qu_unit',
'min' => 0,
'value' => $value,
'invalidFeedback' => $L('The amount cannot be lower than #1', '1')
))
<form id="shopping-list-form" novalidate>
<div class="form-group">
<label for="note">{{ $L('Note') }}</label>
<textarea class="form-control" rows="2" id="note" name="note">@if($mode == 'edit'){{ $listItem->note }}@endif</textarea>
<label for="name">{{ $L('Name') }}</label>
<input type="text" class="form-control" required id="name" name="name" value="@if($mode == 'edit'){{ $shoppingList->name }}@endif">
<div class="invalid-feedback">{{ $L('A name is required') }}</div>
</div>
<button id="save-shoppinglist-button" class="btn btn-success">{{ $L('Save') }}</button>
<div class="form-group">
<label for="description">{{ $L('Description') }}</label>
<textarea class="form-control" rows="2" id="description" name="description">@if($mode == 'edit'){{ $shoppingList->description }}@endif</textarea>
</div>
<button id="save-shopping-list-button" class="btn btn-success">{{ $L('Save') }}</button>
</form>
</div>
<div class="col-xs-12 col-md-6 col-xl-4">
@include('components.productcard')
</div>
</div>
@stop

View File

@ -0,0 +1,56 @@
@extends('layout.default')
@if($mode == 'edit')
@section('title', $L('Edit shopping list item'))
@else
@section('title', $L('Create shopping list item'))
@endif
@section('viewJsName', 'shoppinglistitemform')
@section('content')
<div class="row">
<div class="col-xs-12 col-md-6 col-xl-4 pb-3">
<h1>@yield('title')</h1>
<script>Grocy.EditMode = '{{ $mode }}';</script>
@if($mode == 'edit')
<script>Grocy.EditObjectId = {{ $listItem->id }};</script>
@endif
<form id="shoppinglist-form" novalidate>
@php if($mode == 'edit') { $productId = $listItem->product_id; } else { $productId = ''; } @endphp
@include('components.productpicker', array(
'products' => $products,
'nextInputSelector' => '#amount',
'isRequired' => false,
'prefillById' => $productId
))
@php if($mode == 'edit') { $value = $listItem->amount; } else { $value = 1; } @endphp
@include('components.numberpicker', array(
'id' => 'amount',
'label' => 'Amount',
'hintId' => 'amount_qu_unit',
'min' => 0,
'value' => $value,
'invalidFeedback' => $L('The amount cannot be lower than #1', '1')
))
<div class="form-group">
<label for="note">{{ $L('Note') }}</label>
<textarea class="form-control" rows="2" id="note" name="note">@if($mode == 'edit'){{ $listItem->note }}@endif</textarea>
</div>
<button id="save-shoppinglist-button" class="btn btn-success">{{ $L('Save') }}</button>
</form>
</div>
<div class="col-xs-12 col-md-6 col-xl-4">
@include('components.productcard')
</div>
</div>
@stop